mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
Implement client-side validation for container and bul
This commit is contained in:
@@ -39,12 +39,17 @@ export class Route extends BaseEntity {
|
||||
milestones?: RouteMilestone[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable route label: yard names, not yard codes — "Addis Ababa → Dire Dawa",
|
||||
* not "ADDIS_ABABA → DIRE_DAWA". A yard's display name is its `label`; `code` is the
|
||||
* machine identifier and is only a fallback for a yard missing one.
|
||||
*/
|
||||
export function formatRouteLabel(route: {
|
||||
originYard?: { code?: string; name?: string } | null;
|
||||
destinationYard?: { code?: string; name?: string } | null;
|
||||
originYard?: { code?: string; label?: string } | null;
|
||||
destinationYard?: { code?: string; label?: string } | null;
|
||||
}): string {
|
||||
const origin = route.originYard?.code ?? route.originYard?.name ?? 'Origin';
|
||||
const dest = route.destinationYard?.code ?? route.destinationYard?.name ?? 'Destination';
|
||||
const origin = route.originYard?.label ?? route.originYard?.code ?? 'Origin';
|
||||
const dest = route.destinationYard?.label ?? route.destinationYard?.code ?? 'Destination';
|
||||
return `${origin} → ${dest}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -2503,6 +2503,32 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A reservation on this schedule still has time left to pay.
|
||||
*
|
||||
* The PAYMENT phase ends a hair BEFORE its own reservations do: `paymentPhaseEndsAt`
|
||||
* is stamped when the phase starts, then `reserve()` gives each booking
|
||||
* `now + paymentWindow` a few hundred milliseconds later, one booking at a time. So
|
||||
* the first settle after the phase deadline finds every reservation still in date,
|
||||
* expires nothing, reports `anySettled = false`, runs no top-up — and the caller
|
||||
* concludes the cycle out from under customers who still had time to pay. The next
|
||||
* tick then expires them with no cycle left to promote the waiting list into.
|
||||
*
|
||||
* Callers must not conclude the cycle while this returns true.
|
||||
*/
|
||||
async hasLiveReservations(scheduleId: string): Promise<boolean> {
|
||||
const reserved =
|
||||
await this.bookingsRepository.findReservedForSchedule(scheduleId);
|
||||
const now = Date.now();
|
||||
return reserved.some(
|
||||
(b) =>
|
||||
b.paymentStatus !== "PAID" &&
|
||||
b.status !== "PAID" &&
|
||||
b.paymentDeadline != null &&
|
||||
b.paymentDeadline.getTime() > now,
|
||||
);
|
||||
}
|
||||
|
||||
/** No wagon slots left for allocated + reserved bookings. */
|
||||
async isScheduleFull(scheduleId: string): Promise<boolean> {
|
||||
const schedule =
|
||||
|
||||
@@ -17,6 +17,8 @@ describe('BookingWindowService — window state machine', () => {
|
||||
expireUnacceptedForRouteDay: jest.Mock;
|
||||
settleDueReservations: jest.Mock;
|
||||
isScheduleFull: jest.Mock;
|
||||
hasLiveReservations: jest.Mock;
|
||||
refreshWindowStatus: jest.Mock;
|
||||
};
|
||||
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
||||
let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock };
|
||||
@@ -68,6 +70,9 @@ describe('BookingWindowService — window state machine', () => {
|
||||
expireUnacceptedForRouteDay: jest.fn().mockResolvedValue(undefined),
|
||||
settleDueReservations: jest.fn().mockResolvedValue(undefined),
|
||||
isScheduleFull: jest.fn().mockResolvedValue(false),
|
||||
// No reservation is mid-pay-window by default, so the cycle concludes.
|
||||
hasLiveReservations: jest.fn().mockResolvedValue(false),
|
||||
refreshWindowStatus: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
trainSchedulesRepository = {
|
||||
findById: jest.fn().mockResolvedValue(null),
|
||||
@@ -154,6 +159,26 @@ describe('BookingWindowService — window state machine', () => {
|
||||
expect(batch.settleDueReservations).toHaveBeenCalledWith(scheduleId);
|
||||
});
|
||||
|
||||
it('PAYMENT holds the cycle open while a reservation is still inside its pay window', async () => {
|
||||
// `paymentPhaseEndsAt` is stamped when the phase starts; reserve() then sets each
|
||||
// booking's own deadline milliseconds later. So the phase deadline always passes
|
||||
// first, and concluding here would kill customers who still had time to pay — and
|
||||
// leave no cycle for the waiting-list top-up to run in.
|
||||
batch.hasLiveReservations.mockResolvedValue(true);
|
||||
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);
|
||||
expect(batch.settleDueReservations).toHaveBeenCalledWith(scheduleId);
|
||||
// Still PAYMENT — the cycle was NOT concluded and the window did not reopen.
|
||||
expect(s.windowPhase).toBe('PAYMENT');
|
||||
expect(batch.isScheduleFull).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('conclude: train FULL → window FULL + phase DONE + auto-finalize', async () => {
|
||||
batch.isScheduleFull.mockResolvedValue(true);
|
||||
const s = baseSchedule({ windowPhase: 'PAYMENT' });
|
||||
|
||||
@@ -322,6 +322,20 @@ export class BookingWindowService implements OnModuleInit {
|
||||
return true;
|
||||
}
|
||||
|
||||
// `paymentPhaseEndsAt` is stamped when the phase starts; each reservation's own
|
||||
// deadline is set milliseconds later, per booking, so the phase always expires
|
||||
// a fraction before the reservations it opened. Concluding here would end the
|
||||
// cycle while customers still had time to pay, and the settle that finally
|
||||
// expires them (next tick) would have no cycle left to promote the waiting
|
||||
// list into. Hold in PAYMENT until every reservation has actually resolved.
|
||||
if (await this.bookingBatchService.hasLiveReservations(schedule.id)) {
|
||||
this.logger.log(
|
||||
`[WINDOW] ${schedule.id} PAYMENT phase past its deadline but reservations ` +
|
||||
`are still within their pay windows — holding the cycle open`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
await this.concludeCycle(schedule, cfg, now);
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user