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 5203c3a14..a1029a836 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 @@ -3131,6 +3131,14 @@ export class BookingBatchService implements OnModuleInit { async intercityCapacity(scheduleId: string): Promise<{ budget: CorridorBudget; needFor: (booking: Booking) => Capacity; + /** + * Per-wagon-type split of `needFor(booking).wagons`, against THIS + * schedule's own wagon stock — so the same booking reads differently on a + * different train. Empty when the stock can't be resolved. + */ + breakdownFor: ( + booking: Booking, + ) => Array<{ wagonTypeId: string; code: string; wagons: number }>; } | null> { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); @@ -3145,7 +3153,24 @@ export class BookingBatchService implements OnModuleInit { // still accepts ride-alongs on its empty legs — that is the whole point // of the ride-along flow. const budget = await this.remainingBudget(schedule, limits, wagonDims); - return { budget, needFor: (booking) => this.needFor(booking, wagonDims) }; + // Physical stock of THIS schedule's train (built consist, or the yard fleet + // it will draw from) — what makes the breakdown train-specific. + const stock = await this.trainSchedulingService.wagonStockForSchedule( + schedule.id, + schedule.originStationId, + budget.stops, + ); + return { + budget, + needFor: (booking) => this.needFor(booking, wagonDims), + breakdownFor: (booking) => + this.wagonBreakdownFor( + booking, + wagonDims, + stock.remainingByTypeId, + stock.codesByTypeId, + ), + }; } /** @@ -4094,6 +4119,78 @@ export class BookingBatchService implements OnModuleInit { }; } + /** + * The wagon count of {@link wagonsFor}, split across the wagon TYPES this + * particular train stocks — "3 × N35 + 1 × PW2" rather than a bare 4. + * + * `wagonsFor` sizes the booking on ONE representative type (the first the + * cargo type allows), which is all the abstract budget needs. Staff placing a + * ride-along need the physical picture: how many of each type this schedule + * must actually give up. So each allowed type is sized on its OWN capacity and + * items-fit, then filled greedily from the type with the largest per-wagon + * take, bounded by what the schedule has left of it. + * + * Because the stock is per-schedule, the same booking breaks down differently + * on a train stocking 60T N35s than on one stocking 40T PW2s. Returns [] when + * the booking's types are unconfigured or the train stocks none of them — the + * caller then shows the plain total. + */ + private wagonBreakdownFor( + booking: Booking, + wagonDims: WagonDims, + stockByTypeId: Map, + codesByTypeId: Map, + ): Array<{ wagonTypeId: string; code: string; wagons: number }> { + const total = this.wagonsFor(booking, wagonDims); + if (total <= 0) return []; + + // Per-wagon take of each allowed type ON THIS TRAIN, largest first: a type + // that swallows more of the booking per wagon needs fewer wagons. + const options = this.allowedDimsWithTypes(booking, wagonDims) + .filter((o) => o.wagonTypeId && (stockByTypeId.get(o.wagonTypeId) ?? 0) > 0) + .map((o) => { + const wagonTypeId = o.wagonTypeId as string; + const wagonsIfAlone = Math.max( + 1, + bulkItemWagonsRequired( + booking, + o.dims.capacityTons, + bulkItemsFitFor(booking.cargoType, wagonTypeId), + ) || + (o.dims.capacityTons > 0 + ? Math.ceil(bookingCargoTons(booking) / o.dims.capacityTons) + : total), + ); + return { + wagonTypeId, + code: codesByTypeId.get(wagonTypeId) ?? '—', + available: stockByTypeId.get(wagonTypeId) ?? 0, + // Share of the whole booking one wagon of this type carries. + takePerWagon: 1 / wagonsIfAlone, + }; + }) + .sort((a, b) => b.takePerWagon - a.takePerWagon); + if (!options.length) return []; + + // Fill greedily by take, capped by stock; `remaining` is the fraction of the + // booking still unplaced, so a wagon of any type covers `takePerWagon` of it. + const out: Array<{ wagonTypeId: string; code: string; wagons: number }> = []; + let remaining = 1; + for (const option of options) { + if (remaining <= 1e-9) break; + const wagons = Math.min( + option.available, + Math.ceil(remaining / option.takePerWagon), + ); + if (wagons <= 0) continue; + out.push({ wagonTypeId: option.wagonTypeId, code: option.code, wagons }); + remaining -= wagons * option.takePerWagon; + } + // The train cannot hold the whole booking in the types it stocks — the + // `fits` check already fails it; report only what it CAN take. + return out; + } + private fits(need: Capacity, budget: Capacity): boolean { return ( need.wagons <= budget.wagons && diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.wagon-breakdown.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.wagon-breakdown.spec.ts new file mode 100644 index 000000000..c1ea5b8a1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.wagon-breakdown.spec.ts @@ -0,0 +1,108 @@ +import { BookingBatchService } from './booking-batch.service'; +import { Booking } from '../bookings/entities/booking.entity'; + +/** + * The intercity ride-along board shows WHICH wagon types a booking takes from + * the train it is being placed on ("3 × N35 + 1 × PW2"), not just how many + * wagons. Because the split is drawn against that schedule's own stock, the + * same booking must read differently on a different train. + */ +describe('BookingBatchService — intercity wagon breakdown', () => { + const N35 = 'wagon-type-n35'; + const PW2 = 'wagon-type-pw2'; + + const dims = (capacityTons: number) => ({ + capacityTons, + lengthMeters: 14, + tareWeightTons: 20, + }); + + const wagonDims = { + bulk: dims(60), + container: dims(60), + byWagonTypeId: new Map([ + [N35, dims(60)], + [PW2, dims(20)], + ]), + }; + + const codes = new Map([ + [N35, 'N35'], + [PW2, 'PW2'], + ]); + + /** + * 400 break-bulk items weighing 800t — 2t per item. On a 60t N35 that is 30 + * items per wagon (14 wagons); on a 20t PW2, 10 items (40 wagons). + */ + const perItemBooking = { + id: 'booking-1', + freightType: 'BULK', + cargoTotalWeightVgm: 400, + bulkTotalWeightTons: 800, + bookingContainers: [], + cargoType: { + wagonTypes: [{ id: N35 }, { id: PW2 }], + itemsPerWagonMap: {}, + }, + } as unknown as Booking; + + const service = Object.create( + BookingBatchService.prototype, + ) as BookingBatchService; + + const breakdown = ( + booking: Booking, + stock: Map, + ): Array<{ code: string; wagons: number }> => + ( + service as unknown as { + wagonBreakdownFor: ( + b: Booking, + d: typeof wagonDims, + s: Map, + c: Map, + ) => Array<{ code: string; wagons: number }>; + } + ) + .wagonBreakdownFor(booking, wagonDims, stock, codes) + .map(({ code, wagons }) => ({ code, wagons })); + + it('takes the highest-capacity type first when the train stocks plenty', () => { + const rows = breakdown(perItemBooking, new Map([[N35, 50], [PW2, 50]])); + expect(rows).toEqual([{ code: 'N35', wagons: 14 }]); + }); + + it('falls back to the smaller type for the remainder when the big one runs short', () => { + // Only 10 of the 14 N35s the booking wants — the rest rides PW2s. Ten N35s + // carry 10/14 of the booking, leaving 4/14, which needs ceil(40 × 4/14) PW2s. + const rows = breakdown(perItemBooking, new Map([[N35, 10], [PW2, 50]])); + expect(rows[0]).toEqual({ code: 'N35', wagons: 10 }); + expect(rows[1].code).toBe('PW2'); + expect(rows[1].wagons).toBeGreaterThan(0); + }); + + it('reads differently on a train that stocks only the small type', () => { + const rows = breakdown(perItemBooking, new Map([[PW2, 60]])); + expect(rows).toEqual([{ code: 'PW2', wagons: 40 }]); + }); + + it('honours the configured items-per-wagon fit over raw tonnage', () => { + // Floor space binds before weight: an N35 physically holds 20 of these + // items even though 30 would fit by weight → 20 wagons, not 14. + const floorBound = { + ...perItemBooking, + cargoType: { + wagonTypes: [{ id: N35 }], + itemsPerWagonMap: { [N35]: 20 }, + }, + } as unknown as Booking; + expect(breakdown(floorBound, new Map([[N35, 50]]))).toEqual([ + { code: 'N35', wagons: 20 }, + ]); + }); + + it('returns nothing when the train stocks none of the allowed types', () => { + expect(breakdown(perItemBooking, new Map([['other-type', 30]]))).toEqual([]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts index 5775148d7..4e2724f49 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -155,12 +155,17 @@ export class IntercityService { return { ...this.mapBooking(booking, need), need, + wagonBreakdown: capacity?.breakdownFor(booking) ?? [], fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)), }; }), accepted: accepted.map((booking) => { const need = capacity?.needFor(booking) ?? null; - return { ...this.mapBooking(booking, need), need }; + return { + ...this.mapBooking(booking, need), + need, + wagonBreakdown: capacity?.breakdownFor(booking) ?? [], + }; }), }; } @@ -200,7 +205,9 @@ export class IntercityService { where: { id: bookingId }, relations: { bookingContainers: { containerType: true }, - cargoType: true, + // wagonTypes drives the break-bulk items-per-wagon fit — the accept + // check must size the booking exactly as the candidate list did. + cargoType: { wagonTypes: true }, }, }); if (!booking) { @@ -326,6 +333,10 @@ export class IntercityService { .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') .leftJoinAndSelect('bookingContainer.containerType', 'containerType') .leftJoinAndSelect('booking.cargoType', 'cargoType') + // The allowed wagon-type list is what sizes a break-bulk (PER_ITEM) + // booking: without it `bulkItemsFitFor` reads no items-per-wagon fit and + // the wagon count silently degrades to tonnage-only. + .leftJoinAndSelect('cargoType.wagonTypes', 'cargoWagonType') .leftJoinAndSelect('booking.originYard', 'originYard') .leftJoinAndSelect('booking.destinationYard', 'destinationYard') .where(`booking.trade_direction = 'DOMESTIC'`) @@ -355,6 +366,10 @@ export class IntercityService { .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') .leftJoinAndSelect('bookingContainer.containerType', 'containerType') .leftJoinAndSelect('booking.cargoType', 'cargoType') + // The allowed wagon-type list is what sizes a break-bulk (PER_ITEM) + // booking: without it `bulkItemsFitFor` reads no items-per-wagon fit and + // the wagon count silently degrades to tonnage-only. + .leftJoinAndSelect('cargoType.wagonTypes', 'cargoWagonType') .leftJoinAndSelect('booking.originYard', 'originYard') .leftJoinAndSelect('booking.destinationYard', 'destinationYard') .where(`booking.trade_direction = 'DOMESTIC'`) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index 80030c6c3..406f51988 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -1435,4 +1435,44 @@ describe('TrainSchedulingService', () => { ).rejects.toThrow(/free only 5/); }); }); + + describe('effectiveWagonsRequired', () => { + const effective = (booking: unknown): number => + (service as never as { effectiveWagonsRequired(b: unknown): number }) + .effectiveWagonsRequired(booking); + + // 20-item / 100T break-bulk on 70T wagons with a 4-items-per-wagon fit: + // ceil(20/4) = 5 wagons. + const perItemBooking = (wagonsRequired: number | null) => ({ + freightType: 'BULK', + cargoTotalWeightVgm: 20, + bulkTotalWeightTons: 100, + wagonsRequired, + cargoType: { + wagonTypes: [{ id: 'wt-nw5', capacityTons: 70 }], + itemsPerWagonMap: { 'wt-nw5': 4 }, + }, + }); + + it('overrides a stale too-small stamp with the item-aware recompute', () => { + // Stamped 1 by old code that read the PER_ITEM count (20) as tons. + expect(effective(perItemBooking(1))).toBe(5); + }); + + it('keeps a stored stamp that is at least the recompute', () => { + expect(effective(perItemBooking(7))).toBe(7); + }); + + it('trusts the stamp when BULK cargo relations are not loaded', () => { + expect( + effective({ + freightType: 'BULK', + cargoTotalWeightVgm: 20, + bulkTotalWeightTons: 100, + wagonsRequired: 5, + cargoType: null, + }), + ).toBe(5); + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index a23e763f2..0677907f3 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -7819,7 +7819,7 @@ export class TrainSchedulingService { */ private effectiveWagonsRequired(booking: Booking): number { const stored = Number(booking.wagonsRequired); - if (stored > 0) return Math.ceil(stored); + const storedCeil = stored > 0 ? Math.ceil(stored) : 0; const bulkCapacities = (booking.cargoType?.wagonTypes ?? []) .map((wt) => Number(wt.capacityTons)) .filter((c) => c > 0); @@ -7827,7 +7827,15 @@ export class TrainSchedulingService { booking.freightType === 'BULK' && bulkCapacities.length ? Math.max(...bulkCapacities) : undefined; - return wagonsRequiredForBooking(booking, bulkCapacity); + // BULK with no cargo relations loaded: recomputing would size against a + // 1T capacity and read a PER_ITEM item count as tons — trust the stamp. + if (booking.freightType === 'BULK' && bulkCapacity === undefined && storedCeil > 0) { + return storedCeil; + } + // Stored is a candidate, never an early return (batch parity): rows + // stamped while BULK sizing read the PER_ITEM item count as tons carry a + // too-small footprint — a 20-item/100T booking was stamped 1 wagon. + return Math.max(storedCeil, wagonsRequiredForBooking(booking, bulkCapacity)); } /** diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx index b0a01f625..ecd903128 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx @@ -2,6 +2,7 @@ import { Package } from "lucide-react"; import { SimpleGrid, Divider, Box, Table, Text } from "@mantine/core"; import type { BookingDetail } from "@/types/booking"; +import { cargoTonsAndItems } from "@/utils/cargoWeight"; import { SectionCard } from "./SectionCard"; import { MetricTile } from "./MetricTile"; @@ -13,6 +14,7 @@ export interface BookingCargoCardProps { /** Cargo specs + container manifest table. */ export function BookingCargoCard({ booking }: BookingCargoCardProps) { const containers = booking.bookingContainers ?? []; + const { tons, items } = cargoTonsAndItems(booking); return ( @@ -21,7 +23,8 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) { label="Cargo type" value={booking.cargoType?.label ?? booking.freightType} /> - + + {items != null && } { + const { tons, items } = cargoTonsAndItems(booking); + return items != null ? `${tons} tons (${items} items)` : `${tons} tons`; + })(), + }, { icon: Clock, label: "Last Updated", value: formatDate(booking.updatedAt) }, ]; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx index 3ee5d72a0..ed9802150 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx @@ -22,6 +22,7 @@ import { import type { LucideIcon } from "lucide-react"; import type { BookingDetail } from "@/types/booking"; +import { cargoTonsAndItems } from "@/utils/cargoWeight"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink"; @@ -52,7 +53,7 @@ export function BookingRequestHero({ (sum, c) => sum + Number(c.quantity ?? 0), 0, ); - const weight = Number(booking.cargoTotalWeightVgm ?? 0); + const { tons: weight, items: itemCount } = cargoTonsAndItems(booking); return ( - My signature + Signature & Stamp This signature can be reused to sign booking contracts. diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx index 12ef228df..69dc7f2b8 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx @@ -38,6 +38,7 @@ import { formatRouteLabel } from "@/services/routes.service"; import { useToast } from "@/hooks/use-toast"; import { trainSchedulingService } from "@/services/trainScheduling.service"; import type { BookingDetail } from "@/types/booking"; +import { cargoTonsAndItems } from "@/utils/cargoWeight"; import type { ContainerPlacement, FreightType, @@ -416,7 +417,7 @@ export function AllocateBookingWizard({ const amount = Number(booking.totalAmount); const containers = booking.bookingContainers ?? []; const containerCount = containers.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0); - const weight = Number(booking.cargoTotalWeightVgm ?? 0); + const { tons: weight, items: itemCount } = cargoTonsAndItems(booking); const holdCountdown = formatCountdown(booking.holdExpiresAt); const containerComplete = @@ -945,7 +946,13 @@ export function AllocateBookingWizard({ })}`} hint={booking.paymentStatus} /> - + —; return ( <> - {fmt(need.wagons)} + + {wagonBreakdown?.length ? ( + // Which wagon TYPES this train gives up, not just how many wagons — + // a break-bulk booking's count depends on each type's capacity and + // its configured items-per-wagon fit, so it differs per train. + + {wagonBreakdown.map((entry) => ( + + {entry.wagons} × {entry.code} + + ))} + + ) : ( + fmt(need.wagons) + )} + {fmt(need.weightTons)} t {fmt(need.lengthMeters)} m @@ -285,7 +301,7 @@ export function IntercityRideAlongPanel({ - + {row.fits ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx index 6ea4c40e9..f176fc05d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx @@ -12,6 +12,7 @@ import toast from "react-hot-toast"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad"; +import { StampUpload } from "@/components/contracts/StampUpload"; import { bookingSurface } from "@/components/bookings/booking-ui.styles"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { invalidateBookingDetail } from "@/utils/queryInvalidation"; @@ -40,6 +41,9 @@ export default function BookingContractPage() { const [signOpen, setSignOpen] = useState(false); const [signerName, setSignerName] = useState(""); const [signatureData, setSignatureData] = useState(null); + // Company stamp: prefilled from the profile, or uploaded here when none is + // saved yet. + const [stampData, setStampData] = useState(null); // When the user has a saved signature we offer it for approval first; they // can switch to drawing a fresh one. const [drawNew, setDrawNew] = useState(false); @@ -55,6 +59,7 @@ export default function BookingContractPage() { const savedSignature = data?.savedSignature ?? null; const savedSignatureImage = savedSignature?.signatureImageUrl ?? null; + const savedStampImage = savedSignature?.stampImageUrl ?? null; // Show the approval view only while a saved signature exists and the user // hasn't opted to draw a new one. const usingSaved = Boolean(savedSignatureImage) && !drawNew; @@ -98,6 +103,8 @@ export default function BookingContractPage() { // approve it; otherwise start with an empty pad. setSignerName(savedSignature?.signerDisplayName ?? ""); setSignatureData(null); + // Prefill with the reusable stamp saved on the profile; still replaceable. + setStampData(savedStampImage); setDrawNew(false); setSignOpen(true); }; @@ -106,10 +113,12 @@ export default function BookingContractPage() { if (!canSign || !signerName.trim()) return; // Approve the saved signature, or submit the freshly drawn one. const image = usingSaved ? savedSignatureImage : signatureData; - if (!image) return; + // The API rejects a STAFF signature without a stamp. + if (!image || !stampData) return; signMutation.mutate({ role: "STAFF", signatureImageBase64: image, + stampImageBase64: stampData, signerDisplayName: signerName.trim(), consentText: "I agree to the terms of this contract.", }); @@ -234,6 +243,15 @@ export default function BookingContractPage() { ) : ( )} +