mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 10:10:57 +00:00
enhance train scheduling and booking management
This commit is contained in:
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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"} ` +
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user