mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 14:38:12 +00:00
auto allocation and batch managemnt, tracking the train
This commit is contained in:
@@ -18,13 +18,24 @@ import { TrainSchedulesRepository } from '../train-schedules/train-schedules.rep
|
||||
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
|
||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import {
|
||||
groupByBatchWindow,
|
||||
} from './batch-window.util';
|
||||
import {
|
||||
BATCH_CRON,
|
||||
BATCH_TIMEZONE,
|
||||
DEFAULT_WAGON_LENGTH_METERS,
|
||||
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
DEFAULT_WAGONS_PER_BOOKING,
|
||||
PAYMENT_WINDOW_MS,
|
||||
} from './booking-batch.constants';
|
||||
import {
|
||||
bookingTrainLengthMeters,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
wagonTypeDimensionsFromEntity,
|
||||
} from './train-capacity.util';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
|
||||
/** A train's remaining capacity along the three physical limits the batch enforces. */
|
||||
interface Capacity {
|
||||
@@ -33,9 +44,12 @@ interface Capacity {
|
||||
lengthMeters: number;
|
||||
}
|
||||
|
||||
type WagonLengths = { container: number; bulk: number };
|
||||
|
||||
export type BatchBoardBookingState =
|
||||
| 'ALLOCATED'
|
||||
| 'AWAITING_PAYMENT'
|
||||
| 'SELECTED_FOR_BATCH'
|
||||
| 'READY'
|
||||
| 'WAITING'
|
||||
| 'PENDING_CONTRACT'
|
||||
| 'EXPIRED';
|
||||
@@ -47,10 +61,57 @@ export interface BatchBoardBooking {
|
||||
isGovernment: boolean;
|
||||
wagons: number;
|
||||
weightTons: number;
|
||||
lengthMeters: number;
|
||||
paymentDeadline: string | null;
|
||||
state: BatchBoardBookingState;
|
||||
}
|
||||
|
||||
export type BookingAllocationStatus =
|
||||
| 'NOT_ATTEMPTED'
|
||||
| 'ASSIGNED'
|
||||
| 'DEFERRED'
|
||||
| 'FAILED';
|
||||
|
||||
export interface BatchBoardBookingDetail extends BatchBoardBooking {
|
||||
fullyExecutedAt: string | null;
|
||||
selectedForBatchAt: string | null;
|
||||
allocationStatus: BookingAllocationStatus;
|
||||
allocationIssue: string | null;
|
||||
}
|
||||
|
||||
export interface BatchWindowGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
start: string;
|
||||
end: string;
|
||||
counts: {
|
||||
allocated: number;
|
||||
selectedForBatch: number;
|
||||
ready: number;
|
||||
waiting: number;
|
||||
expired: number;
|
||||
pendingContract: number;
|
||||
};
|
||||
bookings: BatchBoardBookingDetail[];
|
||||
}
|
||||
|
||||
export interface BatchBoardScheduleDetail {
|
||||
scheduleId: string;
|
||||
trainNumber: string | null;
|
||||
routeName: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
scheduleDate: string | null;
|
||||
status: string;
|
||||
bookingWindowStatus: string;
|
||||
locomotive: BatchBoardSchedule['locomotive'];
|
||||
capacity: BatchBoardSchedule['capacity'];
|
||||
counts: BatchBoardSchedule['counts'];
|
||||
windows: BatchWindowGroup[];
|
||||
pendingContract: BatchWindowGroup;
|
||||
allocationViolations: string[];
|
||||
}
|
||||
|
||||
export interface BatchBoardSchedule {
|
||||
scheduleId: string;
|
||||
trainNumber: string | null;
|
||||
@@ -67,15 +128,19 @@ export interface BatchBoardSchedule {
|
||||
maxTrainLengthMeters: number;
|
||||
} | null;
|
||||
capacity: {
|
||||
maxWagons: number;
|
||||
usedWagons: number;
|
||||
remainingWagons: number;
|
||||
/** Wagons on bookings already linked to the train (ALLOCATED only). */
|
||||
allocatedWagons: number;
|
||||
/** Train length used by allocated bookings (from wagon-type dimensions). */
|
||||
allocatedLengthMeters: number;
|
||||
maxLengthMeters: number | null;
|
||||
/** Weight committed on the train (allocated + selected-for-batch). */
|
||||
usedWeightTons: number;
|
||||
maxWeightTons: number | null;
|
||||
};
|
||||
counts: {
|
||||
allocated: number;
|
||||
awaitingPayment: number;
|
||||
selectedForBatch: number;
|
||||
ready: number;
|
||||
waiting: number;
|
||||
pendingContract: number;
|
||||
expired: number;
|
||||
@@ -103,20 +168,121 @@ export class BookingBatchService implements OnModuleInit {
|
||||
private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository,
|
||||
private readonly notifier: BookingNotifierService,
|
||||
private readonly scheduler: SchedulerRegistry,
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
) {}
|
||||
|
||||
/** On boot, re-arm a settle timeout for any schedule that still has live reservations. */
|
||||
/** On boot, reconcile OPEN schedules and re-arm settle timers. */
|
||||
async onModuleInit(): Promise<void> {
|
||||
const open = await this.trainSchedulesRepository.findAll({
|
||||
where: { bookingWindowStatus: 'OPEN' },
|
||||
});
|
||||
for (const s of open) {
|
||||
try {
|
||||
await this.processSchedule(s.id);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Boot reconcile failed for ${s.id}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
const reserved = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.createQueryBuilder('b')
|
||||
.select('DISTINCT b.train_schedule_id', 'scheduleId')
|
||||
.where(`b.status = 'AWAITING_PAYMENT'`)
|
||||
.where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
|
||||
.andWhere('b.train_schedule_id IS NOT NULL')
|
||||
.getRawMany<{ scheduleId: string }>();
|
||||
for (const { scheduleId } of reserved) this.armSettle(scheduleId);
|
||||
}
|
||||
|
||||
/** Fire-and-forget batch pipeline for a schedule (contract sign, cron, payment). */
|
||||
enqueueScheduleProcessing(scheduleId: string): void {
|
||||
void this.processSchedule(scheduleId).catch((err) =>
|
||||
this.logger.error(`processSchedule ${scheduleId} failed: ${(err as Error).message}`),
|
||||
);
|
||||
}
|
||||
|
||||
/** Fill pool, settle due reservations, link orphaned PAID, then assign wagons. */
|
||||
async processSchedule(scheduleId: string): Promise<void> {
|
||||
await this.fillSchedule(scheduleId);
|
||||
await this.settleDueReservations(scheduleId);
|
||||
await this.reconcilePaidUnlinked(scheduleId);
|
||||
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent: link a paid batch booking to its schedule and assign wagons.
|
||||
* Handles SELECTED_FOR_BATCH, PAID-without-link, and PAID-already-linked cases.
|
||||
*/
|
||||
async ensurePaidBookingAllocated(bookingId: string): Promise<void> {
|
||||
const booking = await this.dataSource.getRepository(Booking).findOne({
|
||||
where: { id: bookingId },
|
||||
relations: { company: true },
|
||||
});
|
||||
if (!booking?.trainScheduleId) return;
|
||||
|
||||
const isBatchPaid =
|
||||
booking.status === 'SELECTED_FOR_BATCH' ||
|
||||
booking.status === 'AWAITING_PAYMENT' ||
|
||||
booking.status === 'PAID' ||
|
||||
booking.paymentStatus === 'PAID';
|
||||
if (!isBatchPaid) return;
|
||||
|
||||
if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') {
|
||||
await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.update(bookingId, { paymentStatus: 'PAID', status: 'PAID' });
|
||||
} else if (booking.paymentStatus !== 'PAID') {
|
||||
await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.update(bookingId, { paymentStatus: 'PAID' });
|
||||
}
|
||||
|
||||
const linked = await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
|
||||
if (!linked) {
|
||||
await this.allocate(booking.trainScheduleId, booking, 'paid');
|
||||
this.logger.log(
|
||||
`Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
booking.trainScheduleId,
|
||||
);
|
||||
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
|
||||
await this.setWindow(booking.trainScheduleId, 'FULL');
|
||||
}
|
||||
|
||||
const result = await this.trainSchedulingService.tryAutoWagonAllocation(
|
||||
booking.trainScheduleId,
|
||||
);
|
||||
if (result.assignedBookingIds.length) {
|
||||
this.logger.log(
|
||||
`Wagon allocation for ${booking.reference ?? bookingId}: ${result.assignedBookingIds.length} assigned`,
|
||||
);
|
||||
}
|
||||
if (result.issues.some((i) => i.bookingId === bookingId && i.status !== 'ASSIGNED')) {
|
||||
const issue = result.issues.find((i) => i.bookingId === bookingId);
|
||||
this.logger.warn(
|
||||
`Wagon allocation issue for ${booking.reference ?? bookingId}: ${issue?.issue ?? issue?.status}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Customer paid — delegate to ensurePaidBookingAllocated. */
|
||||
async confirmPaidAndAllocate(bookingId: string): Promise<void> {
|
||||
await this.ensurePaidBookingAllocated(bookingId);
|
||||
}
|
||||
|
||||
/** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */
|
||||
async reconcilePaidUnlinked(scheduleId: string): Promise<void> {
|
||||
const unlinked = await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId);
|
||||
for (const booking of unlinked) {
|
||||
await this.allocate(scheduleId, booking, 'paid');
|
||||
this.logger.log(
|
||||
`Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- cron entry point -----------------------------------------------------
|
||||
|
||||
@Cron(BATCH_CRON, { name: 'booking-batch-fill', timeZone: BATCH_TIMEZONE })
|
||||
@@ -127,7 +293,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
this.logger.log(`Batch fill: ${open.length} OPEN schedule(s).`);
|
||||
for (const s of open) {
|
||||
try {
|
||||
await this.fillSchedule(s.id);
|
||||
await this.processSchedule(s.id);
|
||||
} catch (err) {
|
||||
this.logger.error(`Batch fill failed for ${s.id}: ${(err as Error).message}`);
|
||||
}
|
||||
@@ -152,8 +318,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
order: { scheduledDepartureDate: 'ASC' },
|
||||
});
|
||||
|
||||
const rules = await this.loadGlobalRules();
|
||||
const perWagonLength = this.perWagonLength(rules);
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||||
|
||||
const board: BatchBoardSchedule[] = [];
|
||||
@@ -165,7 +330,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
|
||||
|
||||
const items: BatchBoardBooking[] = bookings.map((b) => {
|
||||
const need = this.needFor(b, perWagonLength);
|
||||
const need = this.needFor(b, wagonLengths);
|
||||
return {
|
||||
id: b.id,
|
||||
reference: b.reference ?? b.id.slice(0, 8),
|
||||
@@ -175,60 +340,223 @@ export class BookingBatchService implements OnModuleInit {
|
||||
isGovernment: Boolean(b.isGovernment),
|
||||
wagons: need.wagons,
|
||||
weightTons: need.weightTons,
|
||||
lengthMeters: need.lengthMeters,
|
||||
paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null,
|
||||
state: this.boardState(b, linkedIds.has(b.id)),
|
||||
};
|
||||
});
|
||||
|
||||
const usedWagons = items
|
||||
.filter((i) => i.state === 'ALLOCATED' || i.state === 'AWAITING_PAYMENT')
|
||||
.reduce((sum, i) => sum + i.wagons, 0);
|
||||
const usedWeight = items
|
||||
.filter((i) => i.state === 'ALLOCATED' || i.state === 'AWAITING_PAYMENT')
|
||||
.reduce((sum, i) => sum + i.weightTons, 0);
|
||||
const loco = s.trainSet?.locomotive ?? null;
|
||||
|
||||
board.push({
|
||||
scheduleId: s.id,
|
||||
trainNumber: s.trainNumber ?? null,
|
||||
routeName: s.route?.name ?? null,
|
||||
origin: s.originStation?.label ?? s.originStation?.code ?? null,
|
||||
destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null,
|
||||
scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null,
|
||||
status: s.status,
|
||||
bookingWindowStatus: s.bookingWindowStatus,
|
||||
locomotive: loco
|
||||
? {
|
||||
code: loco.code,
|
||||
name: loco.name ?? null,
|
||||
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||
}
|
||||
: null,
|
||||
capacity: {
|
||||
maxWagons: s.maxWagons ?? 0,
|
||||
usedWagons,
|
||||
remainingWagons: Math.max(0, (s.maxWagons ?? 0) - usedWagons),
|
||||
usedWeightTons: Math.round(usedWeight * 100) / 100,
|
||||
maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null,
|
||||
},
|
||||
counts: {
|
||||
allocated: items.filter((i) => i.state === 'ALLOCATED').length,
|
||||
awaitingPayment: items.filter((i) => i.state === 'AWAITING_PAYMENT').length,
|
||||
waiting: items.filter((i) => i.state === 'WAITING').length,
|
||||
pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length,
|
||||
expired: items.filter((i) => i.state === 'EXPIRED').length,
|
||||
},
|
||||
bookings: items,
|
||||
});
|
||||
board.push(this.buildScheduleSummary(s, items));
|
||||
}
|
||||
return board;
|
||||
}
|
||||
|
||||
/** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */
|
||||
async getBatchBoardDetail(scheduleId: string): Promise<BatchBoardScheduleDetail> {
|
||||
const s = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!s) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
if (s.status === 'ARRIVED' || s.status === 'CANCELLED') {
|
||||
throw new BadRequestException('Schedule is no longer active');
|
||||
}
|
||||
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||||
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
|
||||
const linkedIds = new Set(links.map((l) => l.bookingId));
|
||||
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
|
||||
|
||||
let allocationPreview: Awaited<
|
||||
ReturnType<TrainSchedulingService['previewAllocationForSchedule']>
|
||||
>;
|
||||
try {
|
||||
allocationPreview = await this.trainSchedulingService.previewAllocationForSchedule(s.id);
|
||||
} catch {
|
||||
allocationPreview = { assignedBookingIds: [], deferred: [], issues: [], violations: [] };
|
||||
}
|
||||
const allocationByBooking = new Map(
|
||||
allocationPreview.issues.map((i) => [i.bookingId, i]),
|
||||
);
|
||||
|
||||
const items: BatchBoardBookingDetail[] = bookings.map((b) => {
|
||||
const need = this.needFor(b, wagonLengths);
|
||||
const alloc = allocationByBooking.get(b.id);
|
||||
return {
|
||||
id: b.id,
|
||||
reference: b.reference ?? b.id.slice(0, 8),
|
||||
company: b.isGovernment
|
||||
? (b.governmentInstitution ?? 'Government')
|
||||
: (b.company?.name ?? '—'),
|
||||
isGovernment: Boolean(b.isGovernment),
|
||||
wagons: need.wagons,
|
||||
weightTons: need.weightTons,
|
||||
lengthMeters: need.lengthMeters,
|
||||
paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null,
|
||||
state: this.boardState(b, linkedIds.has(b.id)),
|
||||
fullyExecutedAt: b.fullyExecutedAt ? b.fullyExecutedAt.toISOString() : null,
|
||||
selectedForBatchAt: b.selectedForBatchAt ? b.selectedForBatchAt.toISOString() : null,
|
||||
allocationStatus: alloc?.status ?? 'NOT_ATTEMPTED',
|
||||
allocationIssue: alloc?.issue ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const loco = s.trainSet?.locomotive ?? null;
|
||||
|
||||
const referenceDate = s.scheduledDepartureDate ?? new Date();
|
||||
const windowBuckets = groupByBatchWindow(
|
||||
items,
|
||||
(item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null),
|
||||
referenceDate,
|
||||
);
|
||||
|
||||
const emptyCounts = () => ({
|
||||
allocated: 0,
|
||||
selectedForBatch: 0,
|
||||
ready: 0,
|
||||
waiting: 0,
|
||||
expired: 0,
|
||||
pendingContract: 0,
|
||||
});
|
||||
|
||||
const countFor = (bookingsInWindow: BatchBoardBookingDetail[]) => {
|
||||
const counts = emptyCounts();
|
||||
for (const b of bookingsInWindow) {
|
||||
if (b.state === 'ALLOCATED') counts.allocated += 1;
|
||||
else if (b.state === 'SELECTED_FOR_BATCH') counts.selectedForBatch += 1;
|
||||
else if (b.state === 'READY') counts.ready += 1;
|
||||
else if (b.state === 'WAITING') counts.waiting += 1;
|
||||
else if (b.state === 'EXPIRED') counts.expired += 1;
|
||||
else counts.pendingContract += 1;
|
||||
}
|
||||
return counts;
|
||||
};
|
||||
|
||||
const windows: BatchWindowGroup[] = [];
|
||||
for (const [key, bucket] of windowBuckets) {
|
||||
if (key === 'pending-contract' || !bucket.window) continue;
|
||||
const w = bucket.window;
|
||||
windows.push({
|
||||
key: w.key,
|
||||
label: w.label,
|
||||
start: w.start.toISOString(),
|
||||
end: w.end.toISOString(),
|
||||
counts: countFor(bucket.items),
|
||||
bookings: bucket.items,
|
||||
});
|
||||
}
|
||||
windows.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
|
||||
|
||||
const pendingBookings = windowBuckets.get('pending-contract')?.items ?? [];
|
||||
|
||||
return {
|
||||
scheduleId: s.id,
|
||||
trainNumber: s.trainNumber ?? null,
|
||||
routeName: s.route?.name ?? null,
|
||||
origin: s.originStation?.label ?? s.originStation?.code ?? null,
|
||||
destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null,
|
||||
scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null,
|
||||
status: s.status,
|
||||
bookingWindowStatus: s.bookingWindowStatus,
|
||||
locomotive: loco
|
||||
? {
|
||||
code: loco.code,
|
||||
name: loco.name ?? null,
|
||||
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||
}
|
||||
: null,
|
||||
capacity: this.computeBoardCapacity(items, loco),
|
||||
counts: {
|
||||
allocated: items.filter((i) => i.state === 'ALLOCATED').length,
|
||||
selectedForBatch: items.filter((i) => i.state === 'SELECTED_FOR_BATCH').length,
|
||||
ready: items.filter((i) => i.state === 'READY').length,
|
||||
waiting: items.filter((i) => i.state === 'WAITING').length,
|
||||
pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length,
|
||||
expired: items.filter((i) => i.state === 'EXPIRED').length,
|
||||
},
|
||||
windows,
|
||||
pendingContract: {
|
||||
key: 'pending-contract',
|
||||
label: 'Pending contract',
|
||||
start: '',
|
||||
end: '',
|
||||
counts: countFor(pendingBookings),
|
||||
bookings: pendingBookings,
|
||||
},
|
||||
allocationViolations: allocationPreview.violations,
|
||||
};
|
||||
}
|
||||
|
||||
/** Run wagon-level allocation for all eligible linked bookings on a schedule. */
|
||||
async runWagonAllocation(scheduleId: string) {
|
||||
return this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
|
||||
}
|
||||
|
||||
private computeBoardCapacity(
|
||||
items: Array<{
|
||||
state: BatchBoardBookingState;
|
||||
wagons: number;
|
||||
weightTons: number;
|
||||
lengthMeters: number;
|
||||
}>,
|
||||
loco: Locomotive | null,
|
||||
): BatchBoardSchedule['capacity'] {
|
||||
const allocated = items.filter((i) => i.state === 'ALLOCATED');
|
||||
const committed = items.filter(
|
||||
(i) => i.state === 'ALLOCATED' || i.state === 'SELECTED_FOR_BATCH',
|
||||
);
|
||||
return {
|
||||
allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0),
|
||||
allocatedLengthMeters:
|
||||
Math.round(allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100) / 100,
|
||||
maxLengthMeters: loco ? Number(loco.maxTrainLengthMeters) : null,
|
||||
usedWeightTons: Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) / 100,
|
||||
maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null,
|
||||
};
|
||||
}
|
||||
|
||||
private buildScheduleSummary(
|
||||
s: TrainSchedule,
|
||||
items: BatchBoardBooking[],
|
||||
): BatchBoardSchedule {
|
||||
const loco = s.trainSet?.locomotive ?? null;
|
||||
|
||||
return {
|
||||
scheduleId: s.id,
|
||||
trainNumber: s.trainNumber ?? null,
|
||||
routeName: s.route?.name ?? null,
|
||||
origin: s.originStation?.label ?? s.originStation?.code ?? null,
|
||||
destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null,
|
||||
scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null,
|
||||
status: s.status,
|
||||
bookingWindowStatus: s.bookingWindowStatus,
|
||||
locomotive: loco
|
||||
? {
|
||||
code: loco.code,
|
||||
name: loco.name ?? null,
|
||||
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||
}
|
||||
: null,
|
||||
capacity: this.computeBoardCapacity(items, loco),
|
||||
counts: {
|
||||
allocated: items.filter((i) => i.state === 'ALLOCATED').length,
|
||||
selectedForBatch: items.filter((i) => i.state === 'SELECTED_FOR_BATCH').length,
|
||||
ready: items.filter((i) => i.state === 'READY').length,
|
||||
waiting: items.filter((i) => i.state === 'WAITING').length,
|
||||
pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length,
|
||||
expired: items.filter((i) => i.state === 'EXPIRED').length,
|
||||
},
|
||||
bookings: items.slice(0, 3),
|
||||
};
|
||||
}
|
||||
|
||||
private boardState(booking: Booking, linked: boolean): BatchBoardBookingState {
|
||||
if (linked) return 'ALLOCATED';
|
||||
if (booking.status === 'AWAITING_PAYMENT') return 'AWAITING_PAYMENT';
|
||||
if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') {
|
||||
return 'SELECTED_FOR_BATCH';
|
||||
}
|
||||
if (booking.status === 'EXPIRED') return 'EXPIRED';
|
||||
if (booking.status === 'FULLY_EXECUTED' && booking.fullyExecutedAt) return 'READY';
|
||||
if (booking.status === 'PAID') return 'WAITING';
|
||||
return 'PENDING_CONTRACT';
|
||||
}
|
||||
@@ -246,9 +574,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
const rules = await this.loadGlobalRules();
|
||||
const perWagonLength = this.perWagonLength(rules);
|
||||
const limits = this.capacityLimits(schedule, locomotive, rules);
|
||||
let budget = await this.remainingCapacity(schedule, limits, perWagonLength);
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
|
||||
let budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
||||
if (budget.wagons <= 0) {
|
||||
await this.setWindow(scheduleId, 'FULL');
|
||||
return;
|
||||
@@ -258,11 +587,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
let armed = false;
|
||||
|
||||
for (const booking of pool) {
|
||||
const need = this.needFor(booking, perWagonLength);
|
||||
const need = this.needFor(booking, wagonLengths);
|
||||
|
||||
if (!this.fits(need, budget)) {
|
||||
if (booking.isGovernment) {
|
||||
budget = await this.preemptForGovernment(scheduleId, need, budget, perWagonLength);
|
||||
budget = await this.preemptForGovernment(scheduleId, need, budget, wagonLengths);
|
||||
if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt
|
||||
} else {
|
||||
continue; // skip a booking that exceeds weight/length/wagons, try the next
|
||||
@@ -281,6 +610,31 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
if (budget.wagons <= 0) await this.setWindow(scheduleId, 'FULL');
|
||||
if (armed) this.armSettle(scheduleId);
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
}
|
||||
|
||||
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
|
||||
async settleDueReservations(scheduleId: string): Promise<void> {
|
||||
const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId);
|
||||
const now = Date.now();
|
||||
let anySettled = false;
|
||||
|
||||
for (const booking of reserved) {
|
||||
const paid = booking.paymentStatus === 'PAID' || booking.status === 'PAID';
|
||||
const expired = booking.paymentDeadline
|
||||
? booking.paymentDeadline.getTime() <= now
|
||||
: false;
|
||||
|
||||
if (paid) {
|
||||
await this.allocate(scheduleId, booking, 'paid');
|
||||
anySettled = true;
|
||||
} else if (expired) {
|
||||
await this.expire(booking);
|
||||
anySettled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (anySettled) await this.fillSchedule(scheduleId);
|
||||
}
|
||||
|
||||
// ---- settle (1h after a batch) -------------------------------------------
|
||||
@@ -306,6 +660,15 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
await this.fillSchedule(scheduleId);
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
}
|
||||
|
||||
private triggerWagonAllocation(scheduleId: string): void {
|
||||
void this.trainSchedulingService.tryAutoWagonAllocation(scheduleId).catch((err) =>
|
||||
this.logger.warn(
|
||||
`Auto wagon allocation failed for ${scheduleId}: ${(err as Error).message}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- staff override actions ----------------------------------------------
|
||||
@@ -330,6 +693,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
|
||||
await this.setWindow(booking.trainScheduleId, 'FULL');
|
||||
}
|
||||
void this.triggerWagonAllocation(booking.trainScheduleId!);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -375,6 +739,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
status: restoredStatus,
|
||||
schedulingStatus: 'ELIGIBLE',
|
||||
paymentDeadline: null,
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
});
|
||||
}
|
||||
@@ -391,14 +756,16 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
// ---- mutations ------------------------------------------------------------
|
||||
|
||||
/** Reserve capacity for a commercial booking and open its 1h pay window. */
|
||||
/** Reserve capacity for a commercial booking and open its pay window. */
|
||||
private async reserve(booking: Booking): Promise<void> {
|
||||
const deadline = new Date(Date.now() + PAYMENT_WINDOW_MS);
|
||||
const now = new Date();
|
||||
const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS);
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: 'AWAITING_PAYMENT',
|
||||
status: 'SELECTED_FOR_BATCH',
|
||||
selectedForBatchAt: now,
|
||||
paymentDeadline: deadline,
|
||||
} as never);
|
||||
this.notifier.payNow(booking, deadline);
|
||||
await this.notifier.payNow(booking, deadline);
|
||||
}
|
||||
|
||||
/** Allocate a booking to the schedule's train (creates the TrainScheduleBooking link). */
|
||||
@@ -423,9 +790,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
schedulingStatus: 'SCHEDULED',
|
||||
scheduledAt: new Date(),
|
||||
paymentDeadline: null,
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
});
|
||||
this.notifier.secured(booking, reason);
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
}
|
||||
|
||||
/** Expire an unpaid reservation and free its capacity. */
|
||||
@@ -434,6 +803,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
status: 'EXPIRED',
|
||||
schedulingStatus: 'ELIGIBLE',
|
||||
paymentDeadline: null,
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
this.notifier.expired(booking);
|
||||
}
|
||||
@@ -446,7 +816,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
scheduleId: string,
|
||||
need: Capacity,
|
||||
budget: Capacity,
|
||||
perWagonLength: number,
|
||||
wagonLengths: WagonLengths,
|
||||
): Promise<Capacity> {
|
||||
const reservedCommercial = (
|
||||
await this.bookingsRepository.findReservedForSchedule(scheduleId)
|
||||
@@ -472,10 +842,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
status: 'EXPIRED',
|
||||
schedulingStatus: 'ELIGIBLE',
|
||||
paymentDeadline: null,
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
});
|
||||
this.notifier.displaced(victim);
|
||||
freed = this.add(freed, this.needFor(victim, perWagonLength));
|
||||
freed = this.add(freed, this.needFor(victim, wagonLengths));
|
||||
}
|
||||
return freed;
|
||||
}
|
||||
@@ -494,12 +865,15 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
/** What one booking consumes along all three capacity axes. */
|
||||
private needFor(booking: Booking, perWagonLength: number): Capacity {
|
||||
private needFor(booking: Booking, wagonLengths: WagonLengths): Capacity {
|
||||
const wagons = this.wagonsFor(booking);
|
||||
return {
|
||||
wagons,
|
||||
weightTons: Number(booking.cargoTotalWeightVgm ?? 0),
|
||||
lengthMeters: wagons * perWagonLength,
|
||||
lengthMeters: bookingTrainLengthMeters(booking.freightType, wagons, {
|
||||
container: wagonLengths.container,
|
||||
bulk: wagonLengths.bulk,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -527,29 +901,71 @@ export class BookingBatchService implements OnModuleInit {
|
||||
};
|
||||
}
|
||||
|
||||
/** The train's hard caps: wagon count, locomotive pull weight, locomotive/global length. */
|
||||
private capacityLimits(
|
||||
schedule: TrainSchedule,
|
||||
/** Locomotive + wagon-type-derived caps (weight, length, wagon slots — not a fixed 53). */
|
||||
private async capacityLimits(
|
||||
locomotive: Locomotive,
|
||||
rules: TrainSchedulingGlobalRules | null,
|
||||
): Capacity {
|
||||
const locoWeight = Number(locomotive.maxPullWeightTons) || Infinity;
|
||||
const locoLength = Number(locomotive.maxTrainLengthMeters) || Infinity;
|
||||
const ruleWeight = rules?.maxTrainWeightTons ? Number(rules.maxTrainWeightTons) : Infinity;
|
||||
const ruleLength = rules?.maxTrainLengthMeters ? Number(rules.maxTrainLengthMeters) : Infinity;
|
||||
): Promise<Capacity> {
|
||||
const wagonTypes = await this.loadWagonTypeDimensions();
|
||||
const derived = deriveTrainCapacityFromLocomotive(
|
||||
{
|
||||
maxPullWeightTons: Number(locomotive.maxPullWeightTons),
|
||||
maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters),
|
||||
},
|
||||
wagonTypes,
|
||||
{
|
||||
maxTrainWeightTons: rules?.maxTrainWeightTons
|
||||
? Number(rules.maxTrainWeightTons)
|
||||
: undefined,
|
||||
maxTrainLengthMeters: rules?.maxTrainLengthMeters
|
||||
? Number(rules.maxTrainLengthMeters)
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
return {
|
||||
wagons: schedule.maxWagons ?? 0,
|
||||
weightTons: Math.min(locoWeight, ruleWeight),
|
||||
lengthMeters: Math.min(locoLength, ruleLength),
|
||||
wagons: derived.maxWagonSlots,
|
||||
weightTons: derived.maxWeightTons,
|
||||
lengthMeters: derived.maxLengthMeters,
|
||||
};
|
||||
}
|
||||
|
||||
/** Per-wagon length, derived from global rules (maxLength / maxWagons) or a fallback. */
|
||||
private perWagonLength(rules: TrainSchedulingGlobalRules | null): number {
|
||||
const len = rules ? Number(rules.maxTrainLengthMeters) : 0;
|
||||
const wagons = rules ? Number(rules.maxWagonsPerTrain) : 0;
|
||||
if (len > 0 && wagons > 0) return len / wagons;
|
||||
return DEFAULT_WAGON_LENGTH_METERS;
|
||||
/** Keep schedule.max_wagons aligned with locomotive physical limits. */
|
||||
private async syncScheduleMaxWagons(
|
||||
schedule: TrainSchedule,
|
||||
locomotive: Locomotive,
|
||||
rules: TrainSchedulingGlobalRules | null,
|
||||
): Promise<void> {
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
if ((schedule.maxWagons ?? 0) !== limits.wagons) {
|
||||
await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.update(schedule.id, { maxWagons: limits.wagons });
|
||||
schedule.maxWagons = limits.wagons;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadWagonTypeDimensions(): Promise<
|
||||
Array<{ lengthMeters: number; capacityTons: number }>
|
||||
> {
|
||||
const types = await this.dataSource.getRepository(WagonType).find({
|
||||
where: [{ code: 'NW5' }, { code: 'CW3' }],
|
||||
});
|
||||
if (types.length) return types.map(wagonTypeDimensionsFromEntity);
|
||||
return [
|
||||
{ lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS, capacityTons: 70 },
|
||||
{ lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS, capacityTons: 60 },
|
||||
];
|
||||
}
|
||||
|
||||
private async loadWagonLengths(): Promise<WagonLengths> {
|
||||
const types = await this.dataSource.getRepository(WagonType).find({
|
||||
where: [{ code: 'NW5' }, { code: 'CW3' }],
|
||||
});
|
||||
const byCode = new Map(types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]));
|
||||
return {
|
||||
container: byCode.get('NW5')?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
bulk: byCode.get('CW3')?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadGlobalRules(): Promise<TrainSchedulingGlobalRules | null> {
|
||||
@@ -560,14 +976,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
private async remainingCapacity(
|
||||
schedule: TrainSchedule,
|
||||
limits: Capacity,
|
||||
perWagonLength: number,
|
||||
wagonLengths: WagonLengths,
|
||||
): Promise<Capacity> {
|
||||
const allocated = (schedule.scheduleBookings ?? [])
|
||||
.map((sb) => sb.booking)
|
||||
.filter((b): b is Booking => Boolean(b));
|
||||
const reserved = await this.bookingsRepository.findReservedForSchedule(schedule.id);
|
||||
const used = [...allocated, ...reserved].reduce<Capacity>(
|
||||
(acc, b) => this.add(acc, this.needFor(b, perWagonLength)),
|
||||
(acc, b) => this.add(acc, this.needFor(b, wagonLengths)),
|
||||
{ wagons: 0, weightTons: 0, lengthMeters: 0 },
|
||||
);
|
||||
return this.subtract(limits, used);
|
||||
|
||||
Reference in New Issue
Block a user