enhance train scheduling and booking management

This commit is contained in:
Marshal
2026-07-07 21:38:45 +00:00
parent 8addfdf3e2
commit 87a95b37bf
13 changed files with 656 additions and 28 deletions

View File

@@ -125,7 +125,10 @@ describe('BookingBatchService — PAID reconcile', () => {
notifier as never,
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
trainSchedulingService as never,
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
{
syncPayableDueDate: jest.fn().mockResolvedValue(undefined),
expirePayable: jest.fn().mockResolvedValue(undefined),
} as never,
{ emitPhase: jest.fn() } as never,
);
});

View File

@@ -80,6 +80,10 @@ export interface BatchBoardBooking {
lengthMeters: number;
paymentDeadline: string | null;
state: BatchBoardBookingState;
/** Rule-engine priority score used to rank the batch (higher = boards first). */
priorityScore: number;
/** CONTAINER | BULK — for the priority-tracking visuals. */
freightType: string | null;
}
export type BookingAllocationStatus =
@@ -638,6 +642,8 @@ export class BookingBatchService implements OnModuleInit {
? b.paymentDeadline.toISOString()
: null,
state: this.boardState(b, linkedIds.has(b.id)),
priorityScore: Number(b.priorityScore ?? 0),
freightType: b.freightType ?? null,
};
});
@@ -726,6 +732,8 @@ export class BookingBatchService implements OnModuleInit {
? b.paymentDeadline.toISOString()
: null,
state: this.boardState(b, linkedIds.has(b.id)),
priorityScore: Number(b.priorityScore ?? 0),
freightType: b.freightType ?? null,
fullyExecutedAt: b.fullyExecutedAt
? b.fullyExecutedAt.toISOString()
: null,
@@ -1026,8 +1034,9 @@ export class BookingBatchService implements OnModuleInit {
const units = this.groupConsolidatedPool(pool);
let armed = false;
// TODO(change-c-diagnostics): remove once the "only 1 reserved" capacity cause
// is confirmed. Logs the caps + pool so we can see which axis rejects unit #2.
// Batch fill trace: caps + pool at entry. Kept on debug level — invaluable when
// reservations trickle instead of landing in one pass (a reserve() throwing
// mid-loop, e.g. schema drift, or a mis-synced capacity cap).
this.logger.debug(
`[fillSchedule ${scheduleId}] limits=${JSON.stringify(limits)} ` +
`maxWagons=${schedule.maxWagons} remaining=${JSON.stringify(budget.maxRemaining())} ` +
@@ -1045,7 +1054,7 @@ export class BookingBatchService implements OnModuleInit {
// stands for the pair.
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
// TODO(change-c-diagnostics): remove once the capacity cause is confirmed.
// Per-unit fit trace: which axis (wagons/weight/length) admits or rejects.
this.logger.debug(
`[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(need)} ` +
`roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)}`,
@@ -1175,8 +1184,7 @@ export class BookingBatchService implements OnModuleInit {
// consolidated booking whose partner isn't ready this cycle is skipped.
const units = this.groupConsolidatedPool(pool);
// TODO(change-c-diagnostics): remove once the "only 1 reserved" capacity cause
// is confirmed. Shows each train's caps + the day pool size.
// Batch fill trace: each train's caps + the day pool size at entry.
this.logger.debug(
`[fillRouteDay ${originYardId}->${destinationYardId} ${day}] ` +
`trains=${trains.map((t) => `${t.id}:${JSON.stringify(t.budget.maxRemaining())}`).join(",")} ` +
@@ -1201,7 +1209,7 @@ export class BookingBatchService implements OnModuleInit {
return leg != null && t.budget.fits(need, leg);
});
// TODO(change-c-diagnostics): remove once the capacity cause is confirmed.
// Per-unit trace: chosen train + each train's remaining room on this leg.
this.logger.debug(
`[fillRouteDay] unit ${booking.reference}: need=${JSON.stringify(need)} ` +
`targetTrain=${target?.id ?? "none"} ` +

View File

@@ -0,0 +1,201 @@
import { BookingWindowService } from './booking-window.service';
import type { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
/**
* Window state-machine tests: exercise the real advanceImport transitions and the
* concludeCycle reopen/done decision with mocked collaborators. Drives the exact
* production phase logic (PRE_WINDOW → OPEN → DOC_REVIEW → PAYMENT → conclude) and
* asserts the side effects the batch/settle/reopen flow depends on.
*/
describe('BookingWindowService — window state machine', () => {
const scheduleId = 'sched-1';
let service: BookingWindowService;
let batch: {
setWindow: jest.Mock;
processRouteDay: jest.Mock;
expireUnacceptedForRouteDay: jest.Mock;
settleDueReservations: jest.Mock;
isScheduleFull: jest.Mock;
};
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock };
let updateMock: jest.Mock;
const cfg = {
importWindowLeadDays: 3,
exportBookingLeadHours: 24,
windowOpenHour: 0, // 24h desk → reopen opens immediately
windowCloseHour: 0,
windowDurationHours: 1,
docReviewMinutes: 30,
paymentWindowMinutes: 60,
reopenDelayMinutes: 0,
};
const baseSchedule = (over: Partial<TrainSchedule>): TrainSchedule =>
({
id: scheduleId,
direction: 'IMPORT',
originStationId: 'yard-o',
destinationStationId: 'yard-d',
scheduledDepartureDate: new Date('2026-08-01T06:00:00.000Z'),
bookingWindowStatus: 'CLOSED',
windowPhase: 'PRE_WINDOW',
bookingCycleNo: 0,
windowOpensAt: null,
windowClosesAt: null,
docReviewEndsAt: null,
docReviewCompletedAt: null,
paymentPhaseEndsAt: null,
...over,
}) as unknown as TrainSchedule;
const advanceImport = (s: TrainSchedule, now: Date): Promise<boolean> =>
(service as unknown as {
advanceImport: (s: TrainSchedule, c: unknown, n: Date) => Promise<boolean>;
}).advanceImport(s, cfg, now);
const concludeCycle = (s: TrainSchedule, now: Date): Promise<void> =>
(service as unknown as {
concludeCycle: (s: TrainSchedule, c: unknown, n: Date) => Promise<void>;
}).concludeCycle(s, cfg, now);
beforeEach(() => {
updateMock = jest.fn().mockResolvedValue(undefined);
batch = {
setWindow: jest.fn().mockResolvedValue(undefined),
processRouteDay: jest.fn().mockResolvedValue(undefined),
expireUnacceptedForRouteDay: jest.fn().mockResolvedValue(undefined),
settleDueReservations: jest.fn().mockResolvedValue(undefined),
isScheduleFull: jest.fn().mockResolvedValue(false),
};
trainSchedulesRepository = {
findById: jest.fn().mockResolvedValue(null),
findAll: jest.fn().mockResolvedValue([]),
};
trainSchedulingService = {
finalizeSchedule: jest.fn().mockResolvedValue(undefined),
getWindowConfig: jest.fn().mockResolvedValue(cfg),
};
service = new BookingWindowService(
{ getRepository: () => ({ update: updateMock }) } as never,
trainSchedulesRepository as never,
batch as never,
trainSchedulingService as never,
{ directSend: jest.fn() } as never,
{ notify: jest.fn() } as never,
{ emitPhase: jest.fn() } as never,
);
});
it('PRE_WINDOW → OPEN at windowOpensAt (opens the customer window)', async () => {
const s = baseSchedule({
windowPhase: 'PRE_WINDOW',
windowOpensAt: new Date('2026-07-01T00:00:00.000Z'),
});
const advanced = await advanceImport(s, new Date('2026-07-01T00:00:01.000Z'));
expect(advanced).toBe(true);
expect(s.windowPhase).toBe('OPEN');
expect(s.bookingCycleNo).toBe(1);
expect(batch.setWindow).toHaveBeenCalledWith(scheduleId, 'OPEN');
});
it('OPEN → DOC_REVIEW at windowClosesAt (closes booking, sets doc-review deadline)', async () => {
const closesAt = new Date('2026-07-01T01:00:00.000Z');
const s = baseSchedule({
windowPhase: 'OPEN',
bookingWindowStatus: 'OPEN',
windowClosesAt: closesAt,
});
const advanced = await advanceImport(s, new Date('2026-07-01T01:00:01.000Z'));
expect(advanced).toBe(true);
expect(s.windowPhase).toBe('DOC_REVIEW');
expect(s.docReviewEndsAt).toEqual(new Date(closesAt.getTime() + 30 * 60_000));
expect(batch.setWindow).toHaveBeenCalledWith(scheduleId, 'CLOSED');
});
it('DOC_REVIEW → PAYMENT expires un-accepted, then runs the batch', async () => {
const s = baseSchedule({
windowPhase: 'DOC_REVIEW',
docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'),
});
const advanced = await advanceImport(s, new Date('2026-07-01T01:30:01.000Z'));
expect(advanced).toBe(true);
expect(s.windowPhase).toBe('PAYMENT');
expect(s.paymentPhaseEndsAt).not.toBeNull();
// Expiry sweep runs BEFORE the batch (unaccepted must not compete for capacity).
expect(batch.expireUnacceptedForRouteDay).toHaveBeenCalledTimes(1);
expect(batch.processRouteDay).toHaveBeenCalledTimes(1);
const expireOrder = batch.expireUnacceptedForRouteDay.mock.invocationCallOrder[0];
const batchOrder = batch.processRouteDay.mock.invocationCallOrder[0];
expect(expireOrder).toBeLessThan(batchOrder);
});
it('DOC_REVIEW → PAYMENT also fires when staff finished review early (docReviewCompletedAt)', async () => {
const s = baseSchedule({
windowPhase: 'DOC_REVIEW',
docReviewEndsAt: new Date('2026-07-01T05:00:00.000Z'), // far future
docReviewCompletedAt: new Date('2026-07-01T01:31:00.000Z'), // staff clicked done
});
const advanced = await advanceImport(s, new Date('2026-07-01T01:31:01.000Z'));
expect(advanced).toBe(true);
expect(s.windowPhase).toBe('PAYMENT');
});
it('PAYMENT → conclude at paymentPhaseEndsAt settles due reservations', async () => {
const s = baseSchedule({
windowPhase: 'PAYMENT',
paymentPhaseEndsAt: new Date('2026-07-01T02:30:00.000Z'),
});
const advanced = await advanceImport(s, new Date('2026-07-01T02:30:01.000Z'));
expect(advanced).toBe(true);
// settleDueReservations runs (allocate paid / expire unpaid, then top-up).
expect(batch.settleDueReservations).toHaveBeenCalledWith(scheduleId);
});
it('conclude: train FULL → window FULL + phase DONE + auto-finalize', async () => {
batch.isScheduleFull.mockResolvedValue(true);
const s = baseSchedule({ windowPhase: 'PAYMENT' });
await concludeCycle(s, new Date('2026-07-01T02:30:02.000Z'));
expect(batch.setWindow).toHaveBeenCalledWith(scheduleId, 'FULL');
expect(s.windowPhase).toBe('DONE');
expect(trainSchedulingService.finalizeSchedule).toHaveBeenCalledWith(scheduleId);
});
it('conclude: NOT full + a cycle fits before departure → REOPEN (back to PRE_WINDOW)', async () => {
batch.isScheduleFull.mockResolvedValue(false);
const s = baseSchedule({
windowPhase: 'PAYMENT',
// departure well in the future so nextCycleOpensAt returns a real time.
scheduledDepartureDate: new Date('2026-08-01T06:00:00.000Z'),
});
await concludeCycle(s, new Date('2026-07-01T02:30:03.000Z'));
expect(s.windowPhase).toBe('PRE_WINDOW');
expect(s.windowOpensAt).not.toBeNull();
expect(trainSchedulingService.finalizeSchedule).not.toHaveBeenCalled();
});
it('conclude: NOT full but NO cycle fits before departure → DONE', async () => {
batch.isScheduleFull.mockResolvedValue(false);
const s = baseSchedule({
windowPhase: 'PAYMENT',
// departure already passed → nextCycleOpensAt returns null → finish.
scheduledDepartureDate: new Date('2026-07-01T00:00:00.000Z'),
});
await concludeCycle(s, new Date('2026-07-01T02:30:04.000Z'));
expect(s.windowPhase).toBe('DONE');
});
it('no transition fires before its deadline (idempotent tick)', async () => {
const s = baseSchedule({
windowPhase: 'OPEN',
bookingWindowStatus: 'OPEN',
windowClosesAt: new Date('2026-07-01T10:00:00.000Z'), // future
});
const advanced = await advanceImport(s, new Date('2026-07-01T01:00:00.000Z'));
expect(advanced).toBe(false);
expect(s.windowPhase).toBe('OPEN');
expect(batch.setWindow).not.toHaveBeenCalled();
});
});