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

@@ -114,6 +114,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingPricingService,
BookingInvoiceService,
BookingLifecycleNotifierService,
ConsolidationService,
CustomerTruckService,
ContainerReceiptService,
],

View File

@@ -272,26 +272,51 @@ export class BookingsRepository extends BaseRepository<Booking> {
}
/**
* Pair two bookings for consolidation. Both return to SUBMITTED so staff can
* accept them into the approval chain; the link itself (consolidationPartnerId)
* marks them as consolidated in the UI.
* Pair two bookings for consolidation. Each returns to its own resume status —
* SUBMITTED for a direct customer booking (so staff can accept it into the
* approval chain) or the stored consolidationResumeStatus for a contract
* drawdown (OPERATION_REQUEST_PENDING / AWAITING_DOCUMENTS). The link itself
* (consolidationPartnerId) marks them as consolidated in the UI. The resume
* status is cleared once used, so a later un-pair re-parks cleanly.
*/
async pairConsolidation(bookingId: string, partnerId: string): Promise<void> {
const [booking, partner] = await Promise.all([
this.repository.findOne({
where: { id: bookingId },
select: { id: true, consolidationResumeStatus: true },
}),
this.repository.findOne({
where: { id: partnerId },
select: { id: true, consolidationResumeStatus: true },
}),
]);
await this.repository.update(bookingId, {
consolidationPartnerId: partnerId,
status: 'SUBMITTED',
status: booking?.consolidationResumeStatus ?? 'SUBMITTED',
consolidationResumeStatus: null,
} as never);
await this.repository.update(partnerId, {
consolidationPartnerId: bookingId,
status: 'SUBMITTED',
status: partner?.consolidationResumeStatus ?? 'SUBMITTED',
consolidationResumeStatus: null,
} as never);
}
/** Park a booking that needs consolidation but has no partner yet. */
async parkForConsolidation(bookingId: string): Promise<void> {
/**
* Park a booking that needs consolidation but has no partner yet. The optional
* resumeStatus is where the booking returns once it pairs — pass it for a
* contract drawdown so pairing resumes the contract-booking flow rather than
* the direct-booking SUBMITTED default.
*/
async parkForConsolidation(
bookingId: string,
resumeStatus?: string | null,
): Promise<void> {
await this.repository.update(bookingId, {
consolidationPartnerId: null,
status: 'PENDING_CONSOLIDATION',
consolidationResumeStatus: resumeStatus ?? null,
} as never);
}

View File

@@ -24,6 +24,7 @@ import {
RuleEngineService,
} from '../rule-engine/rule-engine.service';
import { InjectDataSource } from '@nestjs/typeorm';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { DataSource, In } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
@@ -99,6 +100,7 @@ export class BookingsService {
private readonly consolidationService: ConsolidationService,
private readonly vehiclesService: VehiclesService,
private readonly contractPdfService: ContractPdfService,
private readonly events: EventEmitter2,
) {}
async assignCustomerTruck(
@@ -493,6 +495,14 @@ export class BookingsService {
messages.push(
this.consolidationService.describePaired(partner.reference, slots),
);
// Let deferred owners (e.g. contract drawdowns whose invoice/milestones
// were held while the booking waited) finalize now that a whole wagon
// exists. Fire-and-forget: a listener failure must not undo the pairing.
this.events
.emitAsync('booking.consolidation.paired', {
bookingIds: [booking.id, partner.id],
})
.catch(() => undefined);
return { booking: paired, messages };
}

View File

@@ -431,6 +431,14 @@ export class Booking extends BaseEntity {
@JoinColumn({ name: 'consolidation_partner_id' })
consolidationPartner?: Booking | null;
// Status a booking parked in PENDING_CONSOLIDATION returns to once it pairs.
// Null for direct customer bookings (they resume to SUBMITTED, the historical
// default); contract-drawdown bookings set it to the status createUnderContract
// would otherwise have used (OPERATION_REQUEST_PENDING / AWAITING_DOCUMENTS), so
// pairing resumes them into the right flow instead of the direct-booking one.
@Column({ name: 'consolidation_resume_status', type: 'varchar', length: 40, nullable: true })
consolidationResumeStatus?: string | null;
@Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2, nullable: true })
wagonsRequired?: number | null;

View File

@@ -0,0 +1,181 @@
import { ContractBookingService } from './contract-booking.service';
import { Booking } from '../bookings/entities/booking.entity';
/**
* The GL contract-drawdown path must run wagon consolidation before invoicing.
* A partial-wagon drawdown (e.g. 21× 20FT → one leftover container) parks in
* PENDING_CONSOLIDATION and is NOT finalized (no invoice / milestones) until it
* pairs with a wagon partner. These tests exercise the two new hooks directly.
*/
describe('ContractBookingService — drawdown consolidation gate', () => {
function makeService(overrides: {
consolidationService?: Partial<Record<string, jest.Mock>>;
bookingsRepository?: Partial<Record<string, jest.Mock>>;
invoiceService?: Partial<Record<string, jest.Mock>>;
milestoneService?: Partial<Record<string, jest.Mock>>;
contractsRepository?: Partial<Record<string, jest.Mock>>;
}) {
const consolidationService = {
slotsFromBooking: jest.fn().mockResolvedValue([]),
describePaired: jest.fn().mockReturnValue('paired'),
describePending: jest.fn().mockReturnValue('pending'),
needsConsolidationFromBooking: jest.fn().mockResolvedValue(false),
...overrides.consolidationService,
};
const bookingsRepository = {
findConsolidationPartner: jest.fn().mockResolvedValue(null),
pairConsolidation: jest.fn().mockResolvedValue(undefined),
parkForConsolidation: jest.fn().mockResolvedValue(undefined),
findByIdWithFiles: jest.fn(),
...overrides.bookingsRepository,
};
const invoiceService = {
ensureInvoiceForBooking: jest.fn().mockResolvedValue({ id: 'inv-1' }),
...overrides.invoiceService,
};
const milestoneService = {
seedPostBookingMilestones: jest.fn().mockResolvedValue(undefined),
seedPreBookingMilestonesOnBooking: jest.fn().mockResolvedValue(undefined),
...overrides.milestoneService,
};
const contractsRepository = {
findByIdWithRelations: jest.fn(),
currentCycle: jest.fn().mockResolvedValue(null),
linkBooking: jest.fn().mockResolvedValue(undefined),
update: jest.fn().mockResolvedValue(undefined),
...overrides.contractsRepository,
};
const service = new ContractBookingService(
contractsRepository as never,
bookingsRepository as never,
{} as never, // bookingPricingService
consolidationService as never,
{} as never, // containerTypesService
{} as never, // ruleEngineService
milestoneService as never,
{} as never, // workflowService
invoiceService as never,
{} as never, // dataSource
{} as never, // trainSchedulingService
);
return {
service,
consolidationService,
bookingsRepository,
invoiceService,
milestoneService,
contractsRepository,
};
}
const booking = { id: 'b-1', reference: 'BK-1' } as Booking;
it('parks (not pairs) when no complementary partner exists', async () => {
const { service, bookingsRepository } = makeService({
consolidationService: {
slotsFromBooking: jest
.fn()
.mockResolvedValue([{ containerTypeId: 'ct', slotsNeeded: 1 }]),
},
bookingsRepository: {
findConsolidationPartner: jest.fn().mockResolvedValue(null),
},
});
const result = await (service as never as {
consolidateDrawdown: (b: Booking, s: string) => Promise<{ paired: boolean }>;
}).consolidateDrawdown(booking, 'OPERATION_REQUEST_PENDING');
expect(result.paired).toBe(false);
expect(bookingsRepository.parkForConsolidation).toHaveBeenCalledWith(
'b-1',
'OPERATION_REQUEST_PENDING',
);
expect(bookingsRepository.pairConsolidation).not.toHaveBeenCalled();
});
it('pairs when a complementary partner exists', async () => {
const { service, bookingsRepository } = makeService({
consolidationService: {
slotsFromBooking: jest
.fn()
.mockResolvedValue([{ containerTypeId: 'ct', slotsNeeded: 1 }]),
},
bookingsRepository: {
findConsolidationPartner: jest
.fn()
.mockResolvedValue({ id: 'p-1', reference: 'BK-2' }),
},
});
const result = await (service as never as {
consolidateDrawdown: (b: Booking, s: string) => Promise<{ paired: boolean }>;
}).consolidateDrawdown(booking, 'AWAITING_DOCUMENTS');
expect(result.paired).toBe(true);
expect(bookingsRepository.pairConsolidation).toHaveBeenCalledWith('b-1', 'p-1');
expect(bookingsRepository.parkForConsolidation).not.toHaveBeenCalled();
});
it('onConsolidationPaired finalizes a resumed contract booking (invoice + milestones)', async () => {
const paired = {
id: 'b-1',
reference: 'BK-1',
contractId: 'c-1',
status: 'OPERATION_REQUEST_PENDING',
} as Booking;
const contract = {
id: 'c-1',
contractKind: 'GENERAL',
customsClearingEnabled: true,
tradeDirection: 'EXPORT',
};
const { service, invoiceService, milestoneService } = makeService({
bookingsRepository: {
findByIdWithFiles: jest.fn().mockResolvedValue(paired),
},
contractsRepository: {
findByIdWithRelations: jest.fn().mockResolvedValue(contract),
},
});
await service.onConsolidationPaired({ bookingIds: ['b-1'] });
expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1);
// GENERAL customs → per-booking pre + post milestones.
expect(milestoneService.seedPreBookingMilestonesOnBooking).toHaveBeenCalled();
expect(milestoneService.seedPostBookingMilestones).toHaveBeenCalled();
});
it('onConsolidationPaired ignores a booking still PENDING_CONSOLIDATION', async () => {
const stillPending = {
id: 'b-1',
contractId: 'c-1',
status: 'PENDING_CONSOLIDATION',
} as Booking;
const { service, invoiceService } = makeService({
bookingsRepository: {
findByIdWithFiles: jest.fn().mockResolvedValue(stillPending),
},
});
await service.onConsolidationPaired({ bookingIds: ['b-1'] });
expect(invoiceService.ensureInvoiceForBooking).not.toHaveBeenCalled();
});
it('onConsolidationPaired ignores a non-contract (direct) booking', async () => {
const direct = { id: 'd-1', status: 'SUBMITTED', contractId: null } as Booking;
const { service, invoiceService, contractsRepository } = makeService({
bookingsRepository: {
findByIdWithFiles: jest.fn().mockResolvedValue(direct),
},
});
await service.onConsolidationPaired({ bookingIds: ['d-1'] });
expect(contractsRepository.findByIdWithRelations).not.toHaveBeenCalled();
expect(invoiceService.ensureInvoiceForBooking).not.toHaveBeenCalled();
});
});

View File

@@ -8,6 +8,7 @@ import {
forwardRef,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { OnEvent } from '@nestjs/event-emitter';
import { insertWithGeneratedReference } from '@edr/api-common';
import { Booking } from '../bookings/entities/booking.entity';
@@ -15,6 +16,7 @@ import { BookingContainer } from '../bookings/entities/booking-container.entity'
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { ConsolidationService } from '../bookings/consolidation.service';
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
@@ -61,6 +63,7 @@ export class ContractBookingService {
private readonly contractsRepository: ContractsRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly bookingPricingService: BookingPricingService,
private readonly consolidationService: ConsolidationService,
private readonly containerTypesService: ContainerTypesService,
private readonly ruleEngineService: RuleEngineService,
private readonly milestoneService: ClearanceMilestoneService,
@@ -232,6 +235,105 @@ export class ContractBookingService {
warnings.push(...computed.warnings);
}
// Wagon consolidation gate. A container drawdown whose lines leave a partial
// wagon (e.g. 21× 20FT → one leftover) must share that wagon with a partner
// before it can ship. Direct bookings do this at submit; drawdowns have no
// submit step, so we run it here — BEFORE invoicing/milestones. When it parks
// for a partner the booking is NOT invoiced or scheduled: those steps run
// later in finalizeContractBooking, triggered by the pairing event. When it
// pairs (or needs no consolidation) we finalize inline.
const withContainers = await this.bookingsRepository.findByIdWithFiles(
booking.id,
);
const intendedStatus = generalCustoms
? 'AWAITING_DOCUMENTS'
: 'OPERATION_REQUEST_PENDING';
if (
withContainers &&
freightType === 'CONTAINER' &&
(await this.consolidationService.needsConsolidationFromBooking(
withContainers,
))
) {
const parked = await this.consolidateDrawdown(
withContainers,
intendedStatus,
);
warnings.push(parked.message);
if (!parked.paired) {
// Waiting for a partner — stop here. The booking sits in
// PENDING_CONSOLIDATION, unbilled and unscheduled, until it pairs.
const pendingResult = await this.bookingsRepository.findByIdWithFiles(
booking.id,
);
return { booking: pendingResult ?? booking, warnings };
}
}
await this.finalizeContractBooking(
booking.id,
contract,
generalCustoms,
);
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
return { booking: result ?? booking, warnings };
}
/**
* Search for a complementary partner for a parked-eligible drawdown, pair it or
* park it in PENDING_CONSOLIDATION with the resume status it should return to.
* Pairing (via BookingsRepository.pairConsolidation) resumes both partners and
* emits booking.consolidation.paired, which finalizes any deferred contract
* booking. Returns whether a partner was found plus a customer-facing message.
*/
private async consolidateDrawdown(
booking: Booking,
resumeStatus: string,
): Promise<{ paired: boolean; message: string }> {
const slots = await this.consolidationService.slotsFromBooking(booking);
if (!slots.length) {
return { paired: false, message: '' };
}
const partner = await this.bookingsRepository.findConsolidationPartner(
booking,
slots,
);
if (partner) {
await this.bookingsRepository.pairConsolidation(booking.id, partner.id);
return {
paired: true,
message: this.consolidationService.describePaired(
partner.reference,
slots,
),
};
}
await this.bookingsRepository.parkForConsolidation(booking.id, resumeStatus);
return {
paired: false,
message: this.consolidationService.describePending(booking, slots),
};
}
/**
* Finalize a contract booking once it is cleared to proceed (needed no
* consolidation, or has just paired): seed clearance milestones / link the
* contract cycle, then generate the invoice. Idempotent — safe to call again
* for a booking that pairs after having waited. Skips a booking that is still
* PENDING_CONSOLIDATION (guards the pairing event against a stray partner).
*/
private async finalizeContractBooking(
bookingId: string,
contract: Contract,
generalCustoms: boolean,
): Promise<void> {
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
if (!booking || booking.status === 'PENDING_CONSOLIDATION') return;
// ONE_TIME customs (legacy contract-cycle path): link the contract clearance
// cycle to this booking, seed post-booking milestones, and lock the contract
// to ACTIVE_SHIPMENT_IN_PROGRESS. NOT for GENERAL — it has no contract cycle
@@ -239,10 +341,10 @@ export class ContractBookingService {
if (contract.customsClearingEnabled && !generalCustoms) {
const cycle = await this.contractsRepository.currentCycle(contract.id);
if (cycle) {
await this.contractsRepository.linkBooking(cycle.id, booking.id);
await this.contractsRepository.linkBooking(cycle.id, bookingId);
}
await this.milestoneService.seedPostBookingMilestones(
booking.id,
bookingId,
contract.tradeDirection,
);
await this.contractsRepository.update(contract.id, {
@@ -252,24 +354,22 @@ export class ContractBookingService {
} else if (generalCustoms) {
// Per-booking clearance: seed full milestone timeline on the booking.
await this.milestoneService.seedPreBookingMilestonesOnBooking(
booking.id,
bookingId,
contract.tradeDirection,
);
await this.milestoneService.seedPostBookingMilestones(
booking.id,
bookingId,
contract.tradeDirection,
);
}
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
// Contract bookings are born past the billable gate (the contract is already
// executed), so the invoice is generated here — they never pass through the
// legacy marketingApprove → FULLY_EXECUTED path that invoices direct bookings.
// Idempotent and non-blocking: a billing hiccup must not undo the booking.
// Skips silently when unbillable (no company / no priced amount).
await this.invoiceService
.ensureInvoiceForBooking(result ?? booking)
.ensureInvoiceForBooking(booking)
.catch((err) =>
this.logger.error(
`Failed to generate invoice for contract booking ${booking.reference}: ${
@@ -277,8 +377,40 @@ export class ContractBookingService {
}`,
),
);
}
return { booking: result ?? booking, warnings };
/**
* A parked drawdown just paired — finalize whichever partner is a contract
* booking that was waiting (invoice + milestones deferred at creation). The
* pairing already resumed the booking's status from consolidationResumeStatus;
* this runs the create-time tail that was skipped. Non-contract partners have
* their own finalize path (staff accept) and are ignored here.
*/
@OnEvent('booking.consolidation.paired')
async onConsolidationPaired(payload: {
bookingIds: string[];
}): Promise<void> {
for (const id of payload.bookingIds ?? []) {
const booking = await this.bookingsRepository.findByIdWithFiles(id);
if (!booking?.contractId || booking.status === 'PENDING_CONSOLIDATION') {
continue;
}
const contract = await this.contractsRepository.findByIdWithRelations(
booking.contractId,
);
if (!contract) continue;
const generalCustoms =
contract.contractKind === 'GENERAL' &&
Boolean(contract.customsClearingEnabled);
await this.finalizeContractBooking(id, contract, generalCustoms).catch(
(err) =>
this.logger.error(
`Failed to finalize paired contract booking ${booking.reference}: ${
err instanceof Error ? err.message : String(err)
}`,
),
);
}
}
/**

View File

@@ -7,6 +7,11 @@ import {
import { BaseEntity } from "@edr/api-common";
@Entity({
// Table lives in the freight schema like every other freight entity. Without
// this the entity inherits the DataSource default schema (public), so TypeORM
// queries public.otp_verifications — which doesn't exist — and OTP verify
// (e.g. the contract-signature sudo gate) fails with a 500 QueryFailedError.
schema: "freight",
name: "otp_verifications",
})
export class OtpVerification extends BaseEntity{

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();
});
});