From f736523afd013bde93ecfb68766cc1674e2e9b98 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 9 Jul 2026 08:13:20 +0000 Subject: [PATCH] Implement client-side validation for container and bul --- .../modules/routes/entities/route.entity.ts | 13 ++-- .../train-scheduling/booking-batch.service.ts | 26 +++++++ .../booking-window.service.spec.ts | 25 +++++++ .../booking-window.service.ts | 14 ++++ .../detail/ClearanceReviewSection.tsx | 71 ++++++++++++++----- .../bookings/DocumentClearanceDetailPage.tsx | 1 + .../pages/contracts/GlClearanceDetailPage.tsx | 7 +- .../backoffice/src/services/routes.service.ts | 9 ++- 8 files changed, 140 insertions(+), 26 deletions(-) diff --git a/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts b/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts index a79a54503..23399bb4d 100644 --- a/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts +++ b/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts @@ -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}`; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index cc9e48018..5fb2e7943 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -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 { + 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 { const schedule = diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts index f96c388f8..3286da0eb 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts @@ -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' }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 03e1d12ca..a730b6aa6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -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; } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx index 78cc5efd6..74f9cdb03 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx @@ -47,6 +47,12 @@ export interface ClearanceReviewSectionProps { queriesLocked?: boolean; /** Read-only audit view — no approve/query actions. */ readOnly?: boolean; + /** + * GENERAL customs bookings use the phased milestone workflow (same as + * ONE_TIME contracts): hide the legacy output-documents upload block and the + * finalize button — declaration/duty/transit run in the phased action panel. + */ + phasedCustoms?: boolean; } const STATUS_META: Record< @@ -73,6 +79,7 @@ export function ClearanceReviewSection({ approvalsLocked = false, queriesLocked = false, readOnly = false, + phasedCustoms = false, }: ClearanceReviewSectionProps) { const qc = useQueryClient(); const [queryNotes, setQueryNotes] = useState>({}); @@ -240,7 +247,7 @@ export function ClearanceReviewSection({ - {clearance.outputCode && ( + {clearance.outputCode && !phasedCustoms && ( )} - {finalizeMutation.isError && ( + {!phasedCustoms && finalizeMutation.isError && ( }> {finalizeMutation.error instanceof Error ? finalizeMutation.error.message @@ -349,8 +356,10 @@ export function ClearanceReviewSection({ )} - - + {phasedCustoms ? ( + // Phased (GENERAL customs) — no legacy finalize; the milestone steps in + // the action panel drive the workflow, same as ONE_TIME contracts. + - + {clearance.allApproved ? ( + + ) : ( + + )} {clearance.allApproved - ? "All required documents are approved — you can finalize." - : "Approve every required document to unlock finalization."} + ? "All required documents are approved. Continue declaration, duty, and transit in the action panel." + : "Approve every required document to unlock the customs milestone steps."} - - - + + ) : ( + + + + + + + + {clearance.allApproved + ? "All required documents are approved — you can finalize." + : "Approve every required document to unlock finalization."} + + + + + + )} {viewer} ); diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx index a514cd3ec..2cb5404e7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx @@ -176,6 +176,7 @@ export default function DocumentClearanceDetailPage() { hideSummary approvalsLocked={isPhasedGeneral && docsPhaseComplete} queriesLocked={queriesLocked} + phasedCustoms={isPhasedGeneral} onChanged={() => void refetch()} /> diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx index d5e3f3954..2f42866a9 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx @@ -188,7 +188,12 @@ export default function GlClearanceDetailPage() { {data.kind === "booking" ? ( - + ) : (