From 8b8870e85e4ebdc6502fd00cb6dda1d3f00c9989 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 27 Aug 2026 20:58:44 +0000 Subject: [PATCH] fix issue --- ...booking-wagon-cancellation.service.spec.ts | 49 ++++++ .../booking-wagon-cancellation.service.ts | 13 ++ .../modules/contracts/contracts.repository.ts | 3 + .../modules/contracts/contracts.service.ts | 25 ++++ .../contracts/GlCreateBookingForm.tsx | 130 ++++++++++++---- .../src/pages/contracts/NewShipmentPage.tsx | 139 +++++++++++++----- .../contracts/new-shipment-form/schema.ts | 24 +++ packages/types/src/freight/contracts.ts | 7 + 8 files changed, 327 insertions(+), 63 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts index 07d4c4e06..eaa1ebc0c 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts @@ -1,6 +1,10 @@ import { BadRequestException } from '@nestjs/common'; import { BookingWagonCancellationService } from './booking-wagon-cancellation.service'; +import { + bulkTonWagonsRequired, + bulkTonsPerWagonFor, +} from '../train-scheduling/train-capacity.util'; /** * Sizing of a bulk quantity cut (no DB touched on this branch): a whole-booking @@ -107,3 +111,48 @@ describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () = ).rejects.toThrow(/already shares a wagon/i); }); }); + +/** + * A NUMBER_OF_WAGONS booking pins its count in `bulkRequestedWagons`, and + * bulkTonWagonsRequired honours that verbatim. Partial cancel must shrink it + * alongside wagonsRequired/cargoTotalWeightVgm — left stale, the booking + * re-inflates to its pre-cancel count on the next allocation and each wagon + * carries tons / stale-count instead of the real even share. + */ +describe('partial cancel of a NUMBER_OF_WAGONS bulk booking', () => { + // 980T over 14 wagons (70T each), 2 wagons cancelled. + const before = { freightType: 'BULK', cargoTotalWeightVgm: 980, bulkRequestedWagons: 14 }; + const droppedWeight = 140; + const wagonsCancelled = 2; + + // The decrement applied in applyPaidCut's booking update. + const after = { + ...before, + cargoTotalWeightVgm: before.cargoTotalWeightVgm - droppedWeight, + bulkRequestedWagons: Math.max( + 0, + Math.floor(before.bulkRequestedWagons - wagonsCancelled), + ), + }; + + it('reallocates at the reduced count, not the pre-cancel one', () => { + expect(bulkTonWagonsRequired(before, undefined, 'nw5', 70)).toBe(14); + expect(bulkTonWagonsRequired(after, undefined, 'nw5', 70)).toBe(12); + }); + + it('keeps tons-per-wagon at the real even share', () => { + // Stale count would spread 840T over 14 wagons → 60T each. + expect(bulkTonsPerWagonFor(after, undefined, 'nw5', 70)).toBe(70); + }); + + it('cancelling every wagon leaves no requested count behind', () => { + const all = Math.max(0, Math.floor(before.bulkRequestedWagons - 14)); + expect(all).toBe(0); + expect(bulkTonWagonsRequired( + { ...before, cargoTotalWeightVgm: 0, bulkRequestedWagons: all }, + undefined, + 'nw5', + 70, + )).toBe(0); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index 808421a0c..83ba1548c 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -704,8 +704,21 @@ export class BookingWagonCancellationService { Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled), ); const isFull = wagonsLeft <= 0; + // NUMBER_OF_WAGONS bookings pin their count in bulkRequestedWagons, which + // bulkTonWagonsRequired honours verbatim. Left stale it re-inflates the + // booking to its pre-cancel count on the next allocation (and shrinks + // tons-per-wagon to tons / stale-count), so shrink it with the cut. + const requestedWagonsLeft = booking.bulkRequestedWagons + ? Math.max( + 0, + Math.floor(Number(booking.bulkRequestedWagons) - Number(row.wagonsCancelled)), + ) + : null; await manager.getRepository(Booking).update(booking.id, { wagonsRequired: Math.max(0, wagonsLeft), + ...(requestedWagonsLeft !== null + ? { bulkRequestedWagons: requestedWagonsLeft } + : {}), cargoTotalWeightVgm: Math.max( 0, round3(Number(booking.cargoTotalWeightVgm) - droppedWeight), diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index e289674b2..5b5e73021 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -201,6 +201,9 @@ export class ContractsRepository extends BaseRepository { .leftJoinAndSelect('routes.destinationYard', 'routeDestination') .leftJoinAndSelect('contract.cargoScope', 'cargoScope') .leftJoinAndSelect('cargoScope.cargoType', 'cargoType') + // Wagon types carry the rated capacity the booking forms need to reject + // a wagon count whose even share overloads a wagon (see maxTonsPerWagon). + .leftJoinAndSelect('cargoType.wagonTypes', 'cargoTypeWagonTypes') .leftJoinAndSelect('contract.rateSnapshots', 'rateSnapshots') .leftJoinAndSelect('contract.signatures', 'signatures') .leftJoinAndSelect('signatures.signatureFile', 'signatureFile') diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index bfbc5675f..6c5390fee 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -13,7 +13,9 @@ import { YardCountry } from '@edr/types'; // import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { CompaniesService } from '../companies/companies.service'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity'; +import { bulkTonsPerWagon } from '../train-scheduling/train-capacity.util'; import { Yard } from '../rule-engine/entities/yard.entity'; import { FilesService } from '../files/files.service'; import { MinioService } from '../minio/minio.service'; @@ -858,6 +860,29 @@ export class ContractsService { ); } + // NUMBER_OF_WAGONS booking forms need the heaviest load one wagon may take + // so they can reject a wagon count whose even share overloads a wagon — + // the client-side twin of ContractBookingService.assertWagonShareFits. + // The raw wagonTypes join rows are dropped: only the derived cap ships. + for (const scope of contract.cargoScope ?? []) { + const cargoType = scope.cargoType as + | (CargoType & { maxTonsPerWagon?: number | null }) + | null + | undefined; + if (!cargoType) continue; + const allowed = (cargoType.wagonTypes ?? []).filter( + (wt) => Number(wt.capacityTons) > 0, + ); + cargoType.maxTonsPerWagon = allowed.length + ? Math.max( + ...allowed.map((wt) => + bulkTonsPerWagon(cargoType, wt.id, Number(wt.capacityTons)), + ), + ) + : null; + delete cargoType.wagonTypes; + } + // Surface the staff "request changes" note so the portal can show the // customer what to fix. Degrade to null on lookup failure — a missing note // must never 500 a contract fetch. 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 2e31d2a8c..eaa399f03 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -43,6 +43,7 @@ import { Flame, Link2, MapPin, + MoveRight, Package, Receipt, Repeat, @@ -204,6 +205,19 @@ function emptyLine(size: string): ContainerLineDraft { }; } +/** + * Heaviest load one wagon of this contract's bulk cargo may take, as computed + * by the API from the cargo type's allowed wagon types. Null/undefined when no + * wagon type is configured — the wagon-count check then falls away. + */ +function bulkMaxTonsPerWagon( + contract: Freight.IContract, +): number | null | undefined { + return contract.cargoScope?.find( + (scope) => scope.cargoType?.maxTonsPerWagon != null, + )?.cargoType?.maxTonsPerWagon; +} + function bulkUnitOfMeasure( contract: Freight.IContract, ): "PER_TON" | "PER_ITEM" | "NUMBER_OF_WAGONS" { @@ -1000,8 +1014,20 @@ export default function GlCreateBookingForm() { } if (bulkUom === "NUMBER_OF_WAGONS") { const wagons = Number(bulk.requestedWagons || 0); + const maxPerWagon = Number( + (contract && bulkMaxTonsPerWagon(contract)) || 0, + ); if (!Number.isInteger(wagons) || wagons < 1) { errs.wagons = "Enter the number of wagons needed (at least 1)."; + } else if (qty > 0 && maxPerWagon > 0 && qty / wagons > maxPerWagon) { + // Too few wagons for the tonnage can never ride: 200T across 3 wagons + // is 66.67T each on a 50T wagon. Mirrors the server's + // assertWagonShareFits so the button blocks before the API 400s. + errs.wagons = + `${qty} tons across ${wagons} wagon${wagons === 1 ? "" : "s"} loads ` + + `${Math.round((qty / wagons) * 1000) / 1000}T per wagon, but a wagon of ` + + `this cargo carries at most ${maxPerWagon}T — request at least ` + + `${Math.ceil(qty / maxPerWagon)} wagons.`; } } const h = Number(bulk.hazardousQuantity || 0); @@ -1017,7 +1043,7 @@ export default function GlCreateBookingForm() { errs.reefer = `Can't exceed the cargo quantity (${qty}).`; } return errs; - }, [isContainer, bulk, bulkUom]); + }, [isContainer, bulk, bulkUom, contract]); const dateError = !isIntercity && !scheduledDate ? "Select a shipment date." : undefined; @@ -1479,12 +1505,12 @@ export default function GlCreateBookingForm() { const header = ( - - {completeBookingId ? "Complete Shipment Booking" : "New Shipment Booking"} + <Title order={1} fw={800} fz={28} style={{ letterSpacing: "-0.01em" }}> + {completeBookingId ? "Complete shipment booking" : "New Shipment Booking"} {completeBookingId - ? `Clearance is finalized — enter the cargo details and shipment day to complete the booking under contract ${contract.reference}.` + ? `Clearance is finalized. Enter cargo details and the binding shipment day to complete this booking under contract ${contract.reference}.` : `Book a shipment on behalf of the customer for contract ${contract.reference}.`} @@ -1603,20 +1629,49 @@ export default function GlCreateBookingForm() { styles={fieldStyles} /> ) : ( - - - {selectedRoute?.originYard?.label ?? - selectedRoute?.originYard?.code ?? - "—"}{" "} - →{" "} - {selectedRoute?.destinationYard?.label ?? - selectedRoute?.destinationYard?.code ?? - "—"} - - + + + + {selectedRoute?.originYard?.label ?? + selectedRoute?.originYard?.code ?? + "—"} + + + Origin yard + + + + + + {selectedRoute?.destinationYard?.label ?? + selectedRoute?.destinationYard?.code ?? + "—"} + + + Destination yard + + + + {contract.tradeDirection} - - + + )} @@ -2322,16 +2377,6 @@ export default function GlCreateBookingForm() { even numbers. Add one more 20ft container or remove one — book{" "} {ft20Total + 1} or {ft20Total - 1} instead of {ft20Total}. - ) : showErrors && !formValid ? ( - } - mb="sm" - > - Fix the highlighted fields before reviewing the price. - ) : partnerError ? ( // The review button is disabled while the parent booking is // incomplete, so the click that would reveal the errors never @@ -2346,7 +2391,35 @@ export default function GlCreateBookingForm() { {partnerError} ) : null} - + + + {showErrors && !formValid && !oddBlocksSubmit && ( + <> + + + Fix the highlighted fields to review the price. + + + )} + + + + diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index 8096901e7..544cc9449 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -13,6 +13,7 @@ import { useNavigate, useParams } from "react-router-dom"; import { ActionIcon, Alert, + Badge, Box, Button, Center, @@ -41,6 +42,7 @@ import { FileUp, Flame, MapPin, + MoveRight, Package, Receipt, Repeat, @@ -237,6 +239,19 @@ export default function NewShipmentPage() { ); } +/** + * Heaviest load one wagon of this contract's bulk cargo may take, as computed + * by the API from the cargo type's allowed wagon types. Undefined when no + * wagon type is configured — the wagon-count check then falls away. + */ +function bulkMaxTonsPerWagon( + contract: Freight.IContract, +): number | null | undefined { + return contract.cargoScope?.find( + (scope) => scope.cargoType?.maxTonsPerWagon != null, + )?.cargoType?.maxTonsPerWagon; +} + function bulkUnitOfMeasure( contract: Freight.IContract, ): "PER_TON" | "PER_ITEM" | "NUMBER_OF_WAGONS" { @@ -434,6 +449,7 @@ function NewShipmentBookingForm({ contract.freightType === "CONTAINER" && contract.equipmentReturn === "WITH_RETURN", unitOfMeasure: bulkUnitOfMeasure(contract), + maxTonsPerWagon: bulkMaxTonsPerWagon(contract), // Intercity rides a passing train staff pick later — no date to choose. requiresDate: contract.tradeDirection !== "DOMESTIC", // Export completion locks onto a specific train — the pick is required @@ -688,20 +704,20 @@ function NewShipmentBookingForm({ {completeBookingId ? isResubmit - ? "Change Your Booking" - : "Complete Your Booking" + ? "Change shipment booking" + : "Complete shipment booking" : "New Shipment Booking"} {completeBookingId ? isResubmit ? `Update the details below and pick a new shipment day, then resubmit your booking under contract ${contract.reference}.` - : `Clearance is finalized — enter the cargo details and shipment day to complete your booking under contract ${contract.reference}.` + : `Clearance is finalized. Enter cargo details and the binding shipment day to complete ${completeBooking?.reference ?? "this booking"} under contract ${contract.reference}.` : `Book a shipment against contract ${contract.reference}.`} @@ -791,17 +807,6 @@ function NewShipmentBookingForm({ }} > - {showValidationSummary ? ( - } - mb="sm" - > - Fix the highlighted fields before reviewing the price. - - ) : null} {blockOdd20ft ? ( ) : null} - - + + + {showValidationSummary && ( + <> + + + Fix the highlighted fields to review the price. + + + )} + + + + + @@ -1212,15 +1243,45 @@ function RouteStep({ )} /> ) : ( - - - {routes[0]?.originYard?.label ?? "—"} →{" "} - {routes[0]?.destinationYard?.label ?? "—"} - - + + + + {routes[0]?.originYard?.label ?? "—"} + + + Origin yard + + + + + + {routes[0]?.destinationYard?.label ?? "—"} + + + Destination yard + + + + {contract.tradeDirection} - - + + )} ); @@ -1300,8 +1361,16 @@ function ScheduleStep({ const selectedTrainId = form.watch("trainScheduleId"); const isExportPick = contract.tradeDirection === "EXPORT" && Boolean(completeBookingId); + const requestedWagonsValue = form.watch("requestedWagons"); const wagonsEstimate = useMemo(() => { - if (contract.freightType !== "CONTAINER") return undefined; + // NUMBER_OF_WAGONS bulk states its wagon count outright — pass it through + // so the train picker sizes fits/free against the real need instead of + // falling back to the server's tonnage estimate. + if (contract.freightType !== "CONTAINER") { + if (bulkUnitOfMeasure(contract) !== "NUMBER_OF_WAGONS") return undefined; + const wagons = Math.floor(Number(requestedWagonsValue || 0)); + return wagons >= 1 ? wagons : undefined; + } const lines = containerLines ?? []; const ft20 = lines .filter((l) => l.containerSize === "20ft") @@ -1311,7 +1380,7 @@ function ScheduleStep({ .reduce((s, l) => s + Number(l.quantity || 0), 0); const wagons = Math.ceil(ft20 / 2) + ft40; return wagons > 0 ? wagons : undefined; - }, [contract.freightType, containerLines]); + }, [contract, containerLines, requestedWagonsValue]); const exportTrainsQuery = useQuery({ ...api.bookings.getExportTrains.queryOptions({ input: { diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts index 4c99cf672..5405347b0 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts @@ -25,6 +25,13 @@ export interface ShipmentValidationContext { */ withReturnService?: boolean; unitOfMeasure?: "PER_TON" | "PER_ITEM" | "NUMBER_OF_WAGONS"; + /** + * NUMBER_OF_WAGONS: the most tons one wagon of this cargo may carry. The + * requested count must spread the tonnage no heavier than this, or the + * server rejects the booking (assertWagonShareFits). Undefined when the + * cargo type has no wagon type configured — the check then falls away. + */ + maxTonsPerWagon?: number | null; /** * Intercity (DOMESTIC) shipments ride a passing import/export train that * staff pick later, so no shipment day is chosen. Defaults to true. @@ -312,6 +319,23 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) { path: ["requestedWagons"], message: "Enter the number of wagons needed (at least 1).", }); + } else { + // Too few wagons for the tonnage can never ride: 200T across 3 + // wagons is 66.67T each on a 50T wagon. Mirrors the server's + // assertWagonShareFits so the button blocks before the API 400s. + const tons = Number(data.cargoWeightTons || 0); + const maxPerWagon = Number(ctx.maxTonsPerWagon || 0); + if (tons > 0 && maxPerWagon > 0 && tons / wagons > maxPerWagon) { + refineCtx.addIssue({ + code: "custom", + path: ["requestedWagons"], + message: + `${tons} tons across ${wagons} wagon${wagons === 1 ? "" : "s"} loads ` + + `${Math.round((tons / wagons) * 1000) / 1000}T per wagon, but a wagon of ` + + `this cargo carries at most ${maxPerWagon}T — request at least ` + + `${Math.ceil(tons / maxPerWagon)} wagons.`, + }); + } } } diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index 0b914c3d4..c2392211b 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -179,6 +179,13 @@ export interface IContractCargoScope { code?: string | null; cargoTypeName?: string | null; unitOfMeasure?: string | null; + /** + * Most tons of this cargo one wagon may carry, across the cargo's allowed + * wagon types (rated capacity, capped by the type's loading limit). Lets + * the booking forms reject a wagon count whose even share overloads a + * wagon before the server does. Null when no wagon type is configured. + */ + maxTonsPerWagon?: number | null; } | null; cargoFreeText?: string | null; /**