From e6e4c07077deffcaad706260a637140e648bac49 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 19 Aug 2026 06:20:47 +0000 Subject: [PATCH] feat: implement parity checks for 20ft container bookings to ensure even numbers --- .../bookings/booking-transition.service.ts | 22 +++++++++++ .../contracts/contract-booking.service.ts | 16 ++++++++ .../contracts/GlCreateBookingForm.tsx | 38 +++++++++++++++---- .../new-booking-form/step8-review.tsx | 7 +++- .../contracts/NewShipmentRequestPage.tsx | 20 +++++----- 5 files changed, 84 insertions(+), 19 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 556ce0e98..41e8fc78b 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -81,6 +81,28 @@ export class BookingTransitionService { /** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */ private async assert20ftPairable(booking: Booking): Promise { + // Parity gate. 20ft ride two per wagon, so an odd total leaves one container + // that cannot be placed. Consolidation (pairing it with another customer's + // odd booking) is built end to end but switched off for now, so an odd total + // is rejected here rather than parked for a partner. + // containerSize is not always populated (some rows carry only the container + // type), so fall back to the type's sizeFt rather than silently skipping + // those lines and letting an odd booking through. + const ft20Quantity = (booking.bookingContainers ?? []) + .filter((bc) => + bc.containerSize + ? bc.containerSize.includes("20") + : Number(bc.containerType?.sizeFt) === 20, + ) + .reduce((sum, bc) => sum + Number(bc.quantity || 0), 0); + if (ft20Quantity % 2 === 1) { + throw new BadRequestException( + `20ft containers travel two per wagon, so they must be booked in even ` + + `numbers. This booking has ${ft20Quantity} — add one more or remove ` + + `one (book ${ft20Quantity + 1} or ${ft20Quantity - 1}).`, + ); + } + const violations = await this.containerValidationService.validate20ftPairing(booking); if (violations.length) { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 9e31aff34..e093bd9bf 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -2458,6 +2458,22 @@ export class ContractBookingService { private async assert20ftPairableAtCreate( dto: CreateBookingUnderContractDto, ): Promise { + // Parity gate. 20ft containers ride two per wagon, so an odd total leaves + // one container that cannot be placed. Consolidation (pairing it with + // another customer's odd booking) is built end to end but switched off for + // now, so an odd total is rejected outright — server-side, because the + // frontend block alone is not a guarantee. + const ft20Quantity = (dto.containers ?? []) + .filter((line) => (line.containerSize ?? '').includes('20')) + .reduce((sum, line) => sum + Number(line.quantity || 0), 0); + if (ft20Quantity % 2 === 1) { + throw new BadRequestException( + `20ft containers travel two per wagon, so they must be booked in even ` + + `numbers. This booking has ${ft20Quantity} — add one more or remove ` + + `one (book ${ft20Quantity + 1} or ${ft20Quantity - 1}).`, + ); + } + const twentyFtUnits = (dto.containers ?? []) .filter((line) => (line.containerSize ?? '').includes('20')) .flatMap((line, lineIdx) => diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index ca55338cb..06ae71384 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -874,9 +874,14 @@ export default function GlCreateBookingForm() { // Only a customs (Path B) instance being COMPLETED by GL can use the shared // wagon: it is GL, not the customer, who links the two bookings. Anything else // keeps the historical hard block on odd 20ft. - const oddConsolidationAvailable = Boolean( - completeBookingId && isContainer && contract?.customsClearingEnabled, - ); + // + // Switched OFF for now: consolidation is built end to end (toggle, parent + // picker, split entry, paired pricing, approval gate) but not in use, so an + // odd 20ft total is rejected outright instead of offering the shared wagon. + // Drop the `false &&` to bring the whole flow back. + const oddConsolidationAvailable = + false && + Boolean(completeBookingId && isContainer && contract?.customsClearingEnabled); // Auto-on: entering an odd 20ft total opens the consolidation panel by itself, // once. GL can still switch it off — then odd is blocked exactly as before. @@ -965,10 +970,11 @@ export default function GlCreateBookingForm() { !cargoDescriptionError : !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer; - // The unpaired 20ft container is resolved by the shared wagon, so with an - // active consolidation an odd total stops being a blocker; without one it - // blocks exactly as before. - const oddBlocksSubmit = hasOdd20ft && !consolidationActive; + // Consolidation (sharing the wagon with another customer's odd booking) is + // built but switched off for now, so an odd 20ft total always blocks — the + // shared wagon no longer resolves the unpaired container. Flip this back to + // `hasOdd20ft && !consolidationActive` to re-enable the shared-wagon path. + const oddBlocksSubmit = hasOdd20ft; // Partner side: a linked partner must be picked, carry an odd 20ft count of // its own (odd + odd = even fills the wagon) and have complete unit details. @@ -2168,7 +2174,23 @@ export default function GlCreateBookingForm() { }} > - {showErrors && !formValid ? ( + {/* The review button is disabled on an odd 20ft total, so the click + that would surface the errors never lands — state the reason here + rather than leaving it in a tooltip nobody hovers. */} + {oddBlocksSubmit ? ( + } + mb="sm" + title={`Odd number of 20ft containers (${ft20Total})`} + > + 20ft containers travel two per wagon, so they must be booked in + even numbers. Add one more 20ft container or remove one — book{" "} + {ft20Total + 1} or {ft20Total - 1} instead of {ft20Total}. + + ) : showErrors && !formValid ? ( { + if (hasOdd20ft) return; const dto: Freight.CreateBookingRequestDto = { contractRouteId: route?.id, scheduledDate: hasCustoms ? undefined : scheduledDate || undefined, @@ -220,17 +221,17 @@ export default function NewShipmentRequestPage() { {hasOdd20ft ? ( } title={`Odd number of 20ft containers (${ft20Requested})`} > - 20ft containers travel two per wagon, so one of yours will - share a wagon with another shipment. Global Logistics arranges - the pairing when completing your booking — you are billed only - for your own containers. + 20ft containers travel two per wagon, so they must be requested + in even numbers. Please add one more 20ft container or remove + one (e.g. request {ft20Requested + 1} or {ft20Requested - 1}{" "} + instead of {ft20Requested}). ) : null} @@ -281,6 +282,7 @@ export default function NewShipmentRequestPage() { leftSection={} loading={submit.isPending} onClick={handleSubmit} + disabled={hasOdd20ft} > Submit shipment request