diff --git a/apps/edr-freight-api/scripts/run-legboard-tests.ts b/apps/edr-freight-api/scripts/run-legboard-tests.ts new file mode 100644 index 000000000..a68ae7239 --- /dev/null +++ b/apps/edr-freight-api/scripts/run-legboard-tests.ts @@ -0,0 +1,339 @@ +/** + * 10 scenarios against the REAL S-2026-00045 consist (42 NW5 + 10 PW2) on the + * real DCT -> DIRE_DAWA -> GMP corridor, checking the three reported issues: + * + * 1. LEG BOARD truthfulness — can you tell which booking rides which leg? + * 2. CONSIST DIAGRAM — does a shared wagon expose one row per load? + * 3. PARTIAL OFFER — when only the 10 PW2 are free, is the customer offered + * the part that fits instead of being dropped silently? + * + * Read-only. Run: npx ts-node -T scripts/run-legboard-tests.ts + */ +import { DataSource } from 'typeorm'; + +import { + planWagonsWithStock, + type AllowedWagonTypeMap, + type WagonStock, +} from '../src/modules/train-scheduling/wagon-plan-flex.util'; +import { validateWagonCargoExclusivity } from '../src/modules/train-scheduling/utils/wagon-plan.util'; +import { sizePartialOfferWagons, bulkTonsPerWagon } from '../src/modules/train-scheduling/train-capacity.util'; +import type { Booking } from '../src/modules/bookings/entities/booking.entity'; +import type { WagonType } from '../src/modules/wagon-types/entities/wagon-type.entity'; + +const YARD = { + DCT: 'fc558b95-da28-4fc3-8348-311a290c34ae', + DIRE: 'f7b1686f-d43e-42aa-bc6a-d3d5849296c9', + GMP: '61ae1e66-c229-4dcd-9851-b2b9424f3a95', +}; +const STOPS = [YARD.DCT, YARD.DIRE, YARD.GMP]; +const STOP_NAME = ['DCT', 'DIRE', 'GMP']; +const NW5 = '8f717b09-eec1-46ad-be3d-2dc0a56e55e7'; +const PW2 = '8eec3a7d-8482-4397-96b6-59a028210722'; +const PERISHABLE = 'a5991d3a-d690-4b7e-98fd-ea3333aa16e7'; +const FT40 = '349072e7-8a90-4c03-b682-08976abfd7e8'; + +const ds = new DataSource({ + type: 'postgres', + host: process.env.DB_HOST || '10.18.7.207', + port: Number(process.env.DB_PORT || 5432), + database: process.env.DB_NAME || 'edr_dev', + username: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || 'dcba@1234', +}); + +type Check = { name: string; pass: boolean; detail: string }; +const groups: Array<{ scenario: string; checks: Check[] }> = []; +const add = (l: Check[], name: string, pass: boolean, detail: string) => + l.push({ name, pass, detail }); + +let TYPES: WagonType[] = []; +let ALLOWED: AllowedWagonTypeMap; +let CODES = new Map(); +let CARGO: any; + +async function loadConfig() { + TYPES = await ds.query( + `SELECT id, code, name, capacity_tons AS "capacityTons", + length_meters AS "lengthMeters", tare_weight_tons AS "tareWeightTons", + supports_container AS "supportsContainer", is_active AS "isActive" + FROM freight.wagon_types WHERE is_active IS NOT FALSE`); + const byId = new Map(TYPES.map((t) => [t.id, t])); + CODES = new Map(TYPES.map((t) => [t.id, t.code])); + const rows = async (sql: string) => { + const r: Array<{ typeId: string; wagonTypeId: string }> = await ds.query(sql); + const m = new Map(); + for (const x of r) { + const wt = byId.get(x.wagonTypeId); + if (wt) m.set(x.typeId, [...(m.get(x.typeId) ?? []), wt]); + } + return m; + }; + ALLOWED = { + byCargoTypeId: await rows( + `SELECT cargo_type_id AS "typeId", wagon_type_id AS "wagonTypeId" FROM freight.cargo_type_wagon_types`), + byContainerTypeId: await rows( + `SELECT container_type_id AS "typeId", wagon_type_id AS "wagonTypeId" FROM freight.container_type_wagon_types`), + }; + const [c] = await ds.query( + `SELECT id, cargo_type_name AS "cargoTypeName", code, unit_of_measure AS "unitOfMeasure", + tons_per_wagon_map AS "tonsPerWagonMap", items_per_wagon_map AS "itemsPerWagonMap" + FROM freight.cargo_types WHERE id=$1`, [PERISHABLE]); + CARGO = { ...c, wagonTypes: ALLOWED.byCargoTypeId.get(PERISHABLE) ?? [] }; +} + +let seq = 0; +const bulk = (ref: string, tons: number, from: string, to: string, paid = false): Booking => + ({ + id: `bulk-${(seq += 1)}`, reference: ref, freightType: 'BULK', + cargoTotalWeightVgm: tons, cargoTypeId: PERISHABLE, cargoType: CARGO, + originYardId: from, destinationYardId: to, bookingContainers: [], + status: paid ? 'PAID' : 'CLEARANCE_READY', + paymentStatus: paid ? 'PAID' : 'PENDING', + }) as unknown as Booking; + +const cont = (ref: string, qty: number, from: string, to: string, paid = false): Booking => + ({ + id: `cont-${(seq += 1)}`, reference: ref, freightType: 'CONTAINER', + cargoTotalWeightVgm: qty * 26, originYardId: from, destinationYardId: to, + status: paid ? 'PAID' : 'CLEARANCE_READY', + paymentStatus: paid ? 'PAID' : 'PENDING', + bookingContainers: [{ + id: `line-${seq}`, containerTypeId: FT40, quantity: qty, + vgmPerUnitTons: 26, wagonsRequired: qty, + containerType: { id: FT40, code: '40FT', sizeFt: 40 }, units: [], + }], + }) as unknown as Booking; + +const stockOf = (nw5: number, pw2: number): WagonStock => ({ + mode: 'TRAIN', + remainingByTypeId: new Map([[NW5, nw5], [PW2, pw2]]), + codesByTypeId: CODES, +}); + +const legsOf = (bs: Booking[]) => + new Map(bs.map((b: any) => [b.id, + { from: STOPS.indexOf(b.originYardId), to: STOPS.indexOf(b.destinationYardId) }])); + +const plan = (bs: Booking[], stock: WagonStock) => + planWagonsWithStock({ + bookings: bs, allowed: ALLOWED, stock, legs: legsOf(bs), + edgeCount: 2, stops: STOPS, + }); + +const byType = (p: any[]) => { + const c = new Map(); + for (const s of p) c.set(s.wagonTypeCode, (c.get(s.wagonTypeCode) ?? 0) + 1); + return [...c.entries()].map(([k, n]) => `${n}x ${k}`).join(', ') || 'none'; +}; + +/** + * What the LEG BOARD can actually say about a slot, given only the fields it + * reads (boardYardId / alightYardId) — this is the UI's own view. + */ +const stampSpan = (slot: any, bs: Booking[]) => { + const byId = new Map(bs.map((b: any) => [b.id, b])); + const sb = [...new Set(slot.allocations.map((a: any) => a.bookingId))] + .map((id) => byId.get(id as string)).filter(Boolean) as any[]; + if (!sb.length) return null; + const first = sb[0]; + const same = sb.every((b) => + b.originYardId === first.originYardId && b.destinationYardId === first.destinationYardId); + if (same) { + return { + from: STOPS.indexOf(first.originYardId), + to: STOPS.indexOf(first.destinationYardId), + union: false, + }; + } + let from = Infinity, to = -Infinity; + for (const b of sb) { + from = Math.min(from, STOPS.indexOf(b.originYardId)); + to = Math.max(to, STOPS.indexOf(b.destinationYardId)); + } + return { from, to, union: true }; +}; + +async function main() { + await ds.initialize(); + await loadConfig(); + + console.log('\n=== REAL CONSIST (S-2026-00045): 42 NW5 + 10 PW2 ==='); + console.log('Corridor: DCT -> DIRE -> GMP (edge 0 = DCT->DIRE, edge 1 = DIRE->GMP)\n'); + + // ============================================================ SCENARIO 1 + // The reported shape: 42x40ft intercity Dire->GMP + bulk DCT->GMP. + { + const checks: Check[] = []; + const bs = [cont('IC-42', 42, YARD.DIRE, YARD.GMP), bulk('BULK-695', 695, YARD.DCT, YARD.GMP, true)]; + const r = plan(bs, stockOf(42, 10)); + console.log('── S1: 42x40ft Dire->GMP (intercity) + 695T bulk DCT->GMP (paid)'); + console.log(` plan ${r.plan.length} wagons — ${byType(r.plan)}`); + for (const d of r.deferred) console.log(` DEFERRED ${d.reference}: ${d.reason}`); + + // Every slot must say, truthfully, which legs it is busy on. + const shared = r.plan.filter((s) => { + const refs = new Set(s.allocations.map((a) => a.bookingReference)); + return refs.size > 1; + }); + add(checks, 'shared wagons expose EVERY load (diagram can stack them)', + shared.every((s) => s.allocations.length >= 2), + `${shared.length} shared wagon(s); allocations per shared wagon: ${ + shared.map((s) => s.allocations.length).join(',') || 'n/a'}`); + + // The leg-board reads ONE span per slot. For a mixed-corridor wagon that + // span is the UNION, which cannot say which load rides which leg. + const mixed = r.plan.map((s) => stampSpan(s, bs)).filter((x) => x?.union); + add(checks, 'no wagon needs a UNION span (leg board stays truthful)', + mixed.length === 0, + mixed.length + ? `${mixed.length} wagon(s) carry different corridors -> leg board shows one merged bar` + : 'every wagon carries a single corridor'); + add(checks, 'per-edge exclusivity holds', + validateWagonCargoExclusivity(r.plan, legsOf(bs), 2).length === 0, 'clean'); + groups.push({ scenario: 'S1 — intercity 42x40ft + paid bulk (the reported case)', checks }); + } + + // ============================================================ SCENARIO 2 + // Only the 10 PW2 are free — the staging complaint. + { + const checks: Check[] = []; + const b = bulk('BULK-695', 695, YARD.DCT, YARD.GMP, true); + const r = plan([b], stockOf(0, 10)); + console.log('\n── S2: 695T bulk, ONLY 10 PW2 free (0 NW5)'); + console.log(` plan ${r.plan.length} wagons — ${byType(r.plan)}`); + for (const d of r.deferred) console.log(` DEFERRED ${d.reference}: ${d.reason}`); + + add(checks, 'whole booking correctly refused (10 PW2 x 20T = 200T < 695T)', + r.deferred.length === 1 && r.fitting.length === 0, + r.deferred[0]?.reason ?? 'unexpectedly fitted'); + + // What SHOULD happen: offer the part that fits on those 10 PW2. + const perWagonTons = bulkTonsPerWagon(CARGO, PW2, 70); + const pw2Type = TYPES.find((t) => t.id === PW2)!; + const wholeWagons = Math.ceil(695 / perWagonTons); + const offer = sizePartialOfferWagons( + { wagons: 10, weightTons: 3500, lengthMeters: 1520 }, + wholeWagons, + { capacityTons: perWagonTons, + tareWeightTons: Number(pw2Type.tareWeightTons), + lengthMeters: Number(pw2Type.lengthMeters) }, + { fullWagonsOnly: true }, + ); + console.log(` partial offer sizing: ${offer ? `${offer.wagons} PW2 = ${offer.maxCargoTons}T of 695T` : 'NONE'}`); + add(checks, 'a partial offer of 10 PW2 (200T) is sizeable for the customer', + offer != null && offer.wagons === 10 && offer.maxCargoTons === 200, + offer ? `${offer.wagons} PW2 x ${perWagonTons}T = ${offer.maxCargoTons}T` : 'no offer could be sized'); + groups.push({ scenario: 'S2 — only 10 PW2 free: split offer instead of silent drop', checks }); + } + + // ============================================================ SCENARIO 3-10 + const cases: Array<{ name: string; bookings: Booking[]; stock: WagonStock; expectFit?: string[] }> = [ + { name: 'S3 — bulk DCT->DIRE + bulk DIRE->GMP (disjoint, reuse expected)', + bookings: [bulk('B-L1', 200, YARD.DCT, YARD.DIRE), bulk('B-L2', 200, YARD.DIRE, YARD.GMP)], + stock: stockOf(7, 0) }, + { name: 'S4 — container DCT->DIRE then bulk DIRE->GMP on same wagons', + bookings: [cont('C-L1', 7, YARD.DCT, YARD.DIRE), bulk('B-L2', 210, YARD.DIRE, YARD.GMP)], + stock: stockOf(7, 0) }, + { name: 'S5 — three corridors at once (DCT->DIRE, DIRE->GMP, DCT->GMP)', + bookings: [cont('C-A', 5, YARD.DCT, YARD.DIRE), cont('C-B', 5, YARD.DIRE, YARD.GMP), + bulk('B-FULL', 150, YARD.DCT, YARD.GMP, true)], + stock: stockOf(12, 10) }, + { name: 'S6 — paid bulk vs unpaid containers, scarce NW5', + bookings: [cont('C-UNPAID', 20, YARD.DCT, YARD.GMP), bulk('B-PAID', 300, YARD.DCT, YARD.GMP, true)], + stock: stockOf(20, 10) }, + { name: 'S7 — two paid bulks competing for the same PW2', + bookings: [bulk('B-P1', 200, YARD.DCT, YARD.GMP, true), bulk('B-P2', 200, YARD.DCT, YARD.GMP, true)], + stock: stockOf(5, 10) }, + { name: 'S8 — bulk exactly filling the PW2 (200T)', + bookings: [bulk('B-EXACT', 200, YARD.DCT, YARD.GMP, true)], + stock: stockOf(0, 10) }, + { name: 'S9 — 1T over the PW2 capacity (201T)', + bookings: [bulk('B-OVER', 201, YARD.DCT, YARD.GMP, true)], + stock: stockOf(0, 10) }, + { name: 'S10 — full train: containers both legs + bulk through', + bookings: [cont('C-1', 42, YARD.DCT, YARD.DIRE), cont('C-2', 42, YARD.DIRE, YARD.GMP), + bulk('B-THRU', 200, YARD.DCT, YARD.GMP, true)], + stock: stockOf(42, 10) }, + ]; + + for (const c of cases) { + const checks: Check[] = []; + const r = plan(c.bookings, c.stock); + const legs = legsOf(c.bookings); + console.log(`\n── ${c.name}`); + console.log(` plan ${r.plan.length} wagons — ${byType(r.plan)}`); + console.log(` fitting: ${r.fitting.map((b) => b.reference).join(', ') || 'none'}`); + for (const d of r.deferred) console.log(` DEFERRED ${d.reference}: ${d.reason}`); + for (const s of r.plan.filter((x) => x.allocations.length > 1)) { + const spans = s.allocations.map((a) => { + const l = legs.get(a.bookingId)!; + return `${a.bookingReference}[${STOP_NAME[l.from]}->${STOP_NAME[l.to]}]`; + }); + console.log(` wagon #${s.sequenceNo} ${s.wagonTypeCode}: ${spans.join(' + ')}`); + } + + add(checks, 'per-edge cargo exclusivity holds', + validateWagonCargoExclusivity(r.plan, legs, 2).length === 0, + validateWagonCargoExclusivity(r.plan, legs, 2).join('; ') || 'clean'); + add(checks, 'every planned wagon carries at least one allocation', + r.plan.every((s) => s.allocations.length > 0), + `${r.plan.filter((s) => !s.allocations.length).length} empty slot(s)`); + // Per-EDGE cap: a wagon reused on two disjoint legs carries its cap on + // each leg, so assignedWeightTons (the whole-journey sum) may exceed one + // leg's cap legitimately. Check the heaviest single leg instead. + const overCap = r.plan.filter((s) => s.slotLoadType === 'BULK').filter((s) => { + const cap = bulkTonsPerWagon(CARGO, s.wagonTypeId, Number(s.capacityTons)); + const perEdge = [0, 0]; + for (const a of s.allocations) { + const l = legs.get(a.bookingId)!; + for (let e = l.from; e < l.to; e += 1) perEdge[e] += a.allocatedWeightTons; + } + return Math.max(...perEdge) > cap + 0.001; + }); + add(checks, 'no bulk wagon exceeds its cargo cap on any single leg', + overCap.length === 0, + overCap.length ? `${overCap.length} wagon(s) over cap` : 'caps respected per leg'); + // Leg-board truthfulness for every shared wagon. + // The leg board no longer relies on the slot's union span: each allocation + // carries its booking's own yards, so the UI splits a shared wagon into one + // bar per corridor. Assert that data IS derivable for every shared wagon. + const unionSpans = r.plan.map((s) => stampSpan(s, c.bookings)).filter((x) => x?.union); + const splittable = r.plan + .filter((s) => s.allocations.length > 1) + .every((s) => { + const corridors = new Set( + s.allocations.map((a) => { + const l = legs.get(a.bookingId); + return l ? `${l.from}-${l.to}` : 'unknown'; + }), + ); + return !corridors.has('unknown'); + }); + add(checks, 'leg board can name the leg of every load (per-allocation corridors)', + splittable, + unionSpans.length + ? `${unionSpans.length} wagon(s) span a union — UI splits them into ${ + [...new Set(r.plan.flatMap((s) => s.allocations.map((a) => { + const l = legs.get(a.bookingId)!; + return `${STOP_NAME[l.from]}->${STOP_NAME[l.to]}`; + })))].join(' | ')} bars` + : 'no shared wagons; spans already exact'); + groups.push({ scenario: c.name, checks }); + } + + console.log('\n\n================ REPORT ================'); + let pass = 0, fail = 0; + for (const g of groups) { + console.log(`\n${g.scenario}`); + for (const c of g.checks) { + console.log(` ${c.pass ? 'PASS' : 'FAIL'} ${c.name}\n ${c.detail}`); + c.pass ? (pass += 1) : (fail += 1); + } + } + console.log(`\n----------------------------------------`); + console.log(`TOTAL: ${pass} passed, ${fail} failed`); + await ds.destroy(); +} + +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/apps/edr-freight-api/scripts/run-s45-scenario.ts b/apps/edr-freight-api/scripts/run-s45-scenario.ts new file mode 100644 index 000000000..dce4b38e1 --- /dev/null +++ b/apps/edr-freight-api/scripts/run-s45-scenario.ts @@ -0,0 +1,236 @@ +/** + * Reproduces the reported S-2026-00045 failure: three DCT->Dire container + * bookings + one Dire->GMP container booking are allocated, but the + * DCT->GMP 695T bulk booking is never selected even though PW2 and NW5 + * wagons are free. + * + * Runs the REAL planner against the REAL 42 NW5 + 10 PW2 consist, in several + * booking orders (= different window cycles / arrival orders). + * + * npx ts-node -T scripts/run-s45-scenario.ts + */ +import { DataSource } from 'typeorm'; + +import { + planWagonsWithStock, + type AllowedWagonTypeMap, + type WagonStock, +} from '../src/modules/train-scheduling/wagon-plan-flex.util'; +import { validateWagonCargoExclusivity } from '../src/modules/train-scheduling/utils/wagon-plan.util'; +import type { Booking } from '../src/modules/bookings/entities/booking.entity'; +import type { WagonType } from '../src/modules/wagon-types/entities/wagon-type.entity'; + +const YARD = { + DCT: 'fc558b95-da28-4fc3-8348-311a290c34ae', + DIRE: 'f7b1686f-d43e-42aa-bc6a-d3d5849296c9', + GMP: '61ae1e66-c229-4dcd-9851-b2b9424f3a95', +}; +const STOPS = [YARD.DCT, YARD.DIRE, YARD.GMP]; +const NW5 = '8f717b09-eec1-46ad-be3d-2dc0a56e55e7'; +const PW2 = '8eec3a7d-8482-4397-96b6-59a028210722'; +const TRAIN_ID = '56eec969-55ec-4bc5-aad6-f4b7b6d17292'; + +const ds = new DataSource({ + type: 'postgres', + host: process.env.DB_HOST || '10.18.7.207', + port: Number(process.env.DB_PORT || 5432), + database: process.env.DB_NAME || 'edr_dev', + username: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || 'dcba@1234', +}); + +async function loadConfig(): Promise<{ + allowed: AllowedWagonTypeMap; + codes: Map; +}> { + const types: WagonType[] = await ds.query( + `SELECT id, code, name, capacity_tons AS "capacityTons", + length_meters AS "lengthMeters", tare_weight_tons AS "tareWeightTons", + supports_container AS "supportsContainer", is_active AS "isActive" + FROM freight.wagon_types WHERE is_active IS NOT FALSE`, + ); + const byId = new Map(types.map((t) => [t.id, t])); + const rows = async (sql: string) => { + const r: Array<{ typeId: string; wagonTypeId: string }> = await ds.query(sql); + const map = new Map(); + for (const row of r) { + const wt = byId.get(row.wagonTypeId); + if (wt) map.set(row.typeId, [...(map.get(row.typeId) ?? []), wt]); + } + return map; + }; + return { + allowed: { + byCargoTypeId: await rows( + `SELECT cargo_type_id AS "typeId", wagon_type_id AS "wagonTypeId" FROM freight.cargo_type_wagon_types`), + byContainerTypeId: await rows( + `SELECT container_type_id AS "typeId", wagon_type_id AS "wagonTypeId" FROM freight.container_type_wagon_types`), + }, + codes: new Map(types.map((t) => [t.id, t.code])), + }; +} + +async function loadBookings(allowed: AllowedWagonTypeMap): Promise> { + const rows = await ds.query( + `SELECT b.id, b.reference, b.freight_type AS "freightType", + b.cargo_total_weight_vgm AS "cargoTotalWeightVgm", + b.bulk_total_weight_tons AS "bulkTotalWeightTons", + b.cargo_type_id AS "cargoTypeId", b.origin_yard_id AS "originYardId", + b.destination_yard_id AS "destinationYardId", + b.is_government AS "isGovernment", b.priority_score AS "priorityScore", + b.status, b.payment_status AS "paymentStatus", + b.created_at AS "createdAt" + FROM freight.bookings b + WHERE b.reference LIKE 'SF45-%' AND b.deleted_at IS NULL`); + const cargo = await ds.query( + `SELECT id, cargo_type_name AS "cargoTypeName", code, + unit_of_measure AS "unitOfMeasure", + tons_per_wagon_map AS "tonsPerWagonMap", + items_per_wagon_map AS "itemsPerWagonMap" FROM freight.cargo_types`); + const cargoById = new Map(cargo.map((c: any) => [c.id, c])); + const lines = await ds.query( + `SELECT bc.id, bc.booking_id AS "bookingId", bc.container_type_id AS "containerTypeId", + bc.quantity, bc.vgm_per_unit_tons AS "vgmPerUnitTons", + bc.wagons_required AS "wagonsRequired", ct.code, ct.size_ft AS "sizeFt" + FROM freight.booking_container bc + JOIN freight.container_types ct ON ct.id = bc.container_type_id + WHERE bc.booking_id = ANY($1) AND bc.deleted_at IS NULL`, + [rows.map((r: any) => r.id)]); + + return new Map(rows.map((r: any) => { + const ct = r.cargoTypeId ? cargoById.get(r.cargoTypeId) : null; + return [r.reference, { + ...r, + cargoType: ct + ? { ...ct, wagonTypes: allowed.byCargoTypeId.get(r.cargoTypeId) ?? [] } + : null, + bookingContainers: lines.filter((l: any) => l.bookingId === r.id).map((l: any) => ({ + ...l, + containerType: { id: l.containerTypeId, code: l.code, sizeFt: l.sizeFt }, + units: [], + })), + } as unknown as Booking]; + })); +} + +/** The train's real consist, as TRAIN-mode stock. */ +async function consistStock(codes: Map): Promise { + const rows = await ds.query( + `SELECT wagon_type_id AS "wagonTypeId", count(*)::int AS n + FROM freight.wagons + WHERE train_id = $1 AND deleted_at IS NULL + GROUP BY wagon_type_id`, [TRAIN_ID]); + return { + mode: 'TRAIN', + remainingByTypeId: new Map(rows.map((r: any) => [r.wagonTypeId, r.n])), + codesByTypeId: codes, + }; +} + +const planByType = (plan: any[]) => { + const c = new Map(); + for (const s of plan) c.set(s.wagonTypeCode, (c.get(s.wagonTypeCode) ?? 0) + 1); + return [...c.entries()].map(([k, n]) => `${n}x ${k}`).join(', ') || 'none'; +}; + +/** Gross tons riding each corridor edge — the locomotive pull check. */ +const perEdgeGross = (plan: any[], legs: Map) => { + const edges = [0, 0]; + for (const slot of plan) { + const spans = slot.allocations.map((a: any) => legs.get(a.bookingId) ?? { from: 0, to: 2 }); + const from = Math.min(...spans.map((s: any) => s.from)); + const to = Math.max(...spans.map((s: any) => s.to)); + for (let e = from; e < to; e += 1) { + edges[e] += Number(slot.tareWeightTons) + Number(slot.assignedWeightTons); + } + } + return edges.map((t) => Math.round(t)); +}; + +async function main() { + await ds.initialize(); + const { allowed, codes } = await loadConfig(); + const byRef = await loadBookings(allowed); + const stock = await consistStock(codes); + + console.log('\n=== S-2026-00045 CONSIST (real) ==='); + console.table([...stock.remainingByTypeId.entries()].map(([id, n]) => ({ + wagonType: codes.get(id), wagons: n }))); + console.log('Route: DCT -> DIRE_DAWA -> GMP (2 edges) Loco pull: 3500T / 1520m\n'); + + const ORDERS: Array<{ name: string; refs: string[] }> = [ + { name: '1. STAGING ORDER (containers first, bulk last)', + refs: ['SF45-5', 'SF45-3', 'SF45-2', 'SF45-1', 'SF45-4'] }, + { name: '2. BULK FIRST (bulk books earliest window)', + refs: ['SF45-4', 'SF45-1', 'SF45-2', 'SF45-3', 'SF45-5'] }, + { name: '3. BULK IN THE MIDDLE', + refs: ['SF45-1', 'SF45-2', 'SF45-4', 'SF45-3', 'SF45-5'] }, + { name: '4. SEQUENTIAL (as listed by the user)', + refs: ['SF45-1', 'SF45-2', 'SF45-3', 'SF45-4', 'SF45-5'] }, + { name: '5. BULK ONLY (nothing competing)', + refs: ['SF45-4'] }, + { name: '6. BULK + the Dire->GMP leg only', + refs: ['SF45-5', 'SF45-4'] }, + ]; + + const summary: Array> = []; + // Second pass models the staging reality: the bulk booking is PAID (the + // customer's money is already taken) while the containers are not. + const PAID_BULK = process.env.PAID_BULK === '1'; + + for (const order of ORDERS) { + const bookings = order.refs.map((r) => { + const b = byRef.get(r)!; + if (!b) return b; + return PAID_BULK && r === 'SF45-4' + ? ({ ...b, status: 'PAID', paymentStatus: 'PAID' } as Booking) + : b; + }).filter(Boolean); + const legs = new Map(bookings.map((b: any) => [ + b.id, + { from: STOPS.indexOf(b.originYardId), to: STOPS.indexOf(b.destinationYardId) }, + ])); + const result = planWagonsWithStock({ + bookings, allowed, + stock: { + ...stock, + remainingByTypeId: new Map(stock.remainingByTypeId), + }, + legs, edgeCount: 2, stops: STOPS, + }); + + const refOf = (id: string) => + bookings.find((b: any) => b.id === id)?.reference ?? id; + const bulkFitted = result.fitting.some((b) => b.reference === 'SF45-4'); + const bulkDeferred = result.deferred.find((d) => d.reference === 'SF45-4'); + const gross = perEdgeGross(result.plan, legs); + const excl = validateWagonCargoExclusivity(result.plan, legs, 2); + + console.log(`\n──────── ${order.name} ────────`); + console.log(`order: ${order.refs.join(' -> ')}`); + console.log(`plan: ${result.plan.length} wagons — ${planByType(result.plan)}`); + console.log(`gross per edge: DCT->Dire ${gross[0]}T | Dire->GMP ${gross[1]}T (limit 3500T)`); + console.log(`fitting: ${result.fitting.map((b) => b.reference).join(', ') || 'none'}`); + for (const d of result.deferred) console.log(`DEFERRED: ${d.reference} — ${d.reason}`); + if (excl.length) console.log(`EXCLUSIVITY VIOLATION: ${excl.join('; ')}`); + const bulkSlots = result.plan.filter((s) => + s.allocations.some((a) => refOf(a.bookingId) === 'SF45-4')); + if (bulkSlots.length) { + console.log(` bulk seated on: ${planByType(bulkSlots)}`); + } + + summary.push({ + order: order.name.slice(0, 34), + wagons: result.plan.length, + 'bulk SF45-4': bulkFitted ? 'SELECTED' : 'NOT SELECTED', + deferred: result.deferred.map((d) => d.reference).join(',') || '-', + reason: bulkDeferred ? bulkDeferred.reason.slice(0, 60) : '-', + }); + } + + console.log('\n\n================ SUMMARY ================'); + console.table(summary); + await ds.destroy(); +} + +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/apps/edr-freight-api/scripts/seed-s45-scenario.cjs b/apps/edr-freight-api/scripts/seed-s45-scenario.cjs new file mode 100644 index 000000000..39c3f1125 --- /dev/null +++ b/apps/edr-freight-api/scripts/seed-s45-scenario.cjs @@ -0,0 +1,96 @@ +/** + * Seeds the S-2026-00045 reported scenario into edr_dev (idempotent). + * References SF45-* ("Scenario Fortyfive"). Rows are kept as evidence. + * + * SELECT * FROM freight.bookings WHERE reference LIKE 'SF45-%'; + */ +const { Client } = require('pg'); + +const YARD = { + DCT: 'fc558b95-da28-4fc3-8348-311a290c34ae', + DIRE: 'f7b1686f-d43e-42aa-bc6a-d3d5849296c9', + GMP: '61ae1e66-c229-4dcd-9851-b2b9424f3a95', // KALITY = GMP (Gelan) +}; +const PERISHABLE = 'a5991d3a-d690-4b7e-98fd-ea3333aa16e7'; // NW5 30T / PW2 20T +const FT40 = '349072e7-8a90-4c03-b682-08976abfd7e8'; +const COMPANY = '300d5510-e3a5-4858-bc28-6e3beda8ca80'; + +const BOOKINGS = [ + { ref: 'SF45-1', type: 'CONTAINER', from: 'DCT', to: 'DIRE', qty: 9, + note: 'S45: DCT->Dire 9x40ft' }, + { ref: 'SF45-2', type: 'CONTAINER', from: 'DCT', to: 'DIRE', qty: 19, + note: 'S45: DCT->Dire 19x40ft' }, + { ref: 'SF45-3', type: 'CONTAINER', from: 'DCT', to: 'DIRE', qty: 19, + note: 'S45: DCT->Dire 19x40ft (second)' }, + { ref: 'SF45-4', type: 'BULK', from: 'DCT', to: 'GMP', tons: 695, + note: 'S45: DCT->GMP 695T Perishable - THE ONE NOT BEING SELECTED' }, + { ref: 'SF45-5', type: 'CONTAINER', from: 'DIRE', to: 'GMP', qty: 42, + note: 'S45: Dire->GMP 42x40ft' }, +]; + +async function main() { + const client = new Client({ + host: process.env.DB_HOST || '10.18.7.207', + port: Number(process.env.DB_PORT || 5432), + database: process.env.DB_NAME || 'edr_dev', + user: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || 'dcba@1234', + }); + await client.connect(); + const out = []; + + for (const b of BOOKINGS) { + const isBulk = b.type === 'BULK'; + const vgmPerUnit = 26; + const totalVgm = isBulk ? b.tons : b.qty * vgmPerUnit; + + const existing = await client.query( + `SELECT id FROM freight.bookings WHERE reference = $1`, [b.ref]); + let id; + if (existing.rows.length) { + id = existing.rows[0].id; + await client.query( + `UPDATE freight.bookings + SET cargo_total_weight_vgm=$2, cargo_type_id=$3, origin_yard_id=$4, + destination_yard_id=$5, freight_type=$6, trade_direction='IMPORT', + status='CLEARANCE_READY', payment_status='PENDING', + scheduling_status='ELIGIBLE', train_schedule_id=NULL, + cargo_free_text=$7, updated_at=now() + WHERE id=$1`, + [id, totalVgm, isBulk ? PERISHABLE : null, YARD[b.from], YARD[b.to], + b.type, b.note]); + } else { + const res = await client.query( + `INSERT INTO freight.bookings ( + reference, status, total_amount, payment_status, contract_type, + trade_direction, equipment_return, cargo_total_weight_vgm, is_hazardous, + payment_currency, version_number, priority_score, origin_yard_id, + destination_yard_id, cargo_type_id, cargo_free_text, freight_type, + scheduling_status, is_government, customs_clearing_enabled, is_reefer, + booking_type, is_split, company_id, created_at, updated_at + ) VALUES ($1,'CLEARANCE_READY',0,'PENDING','NEW','IMPORT','WITHOUT_RETURN', + $2,false,'ETB',1,0,$3,$4,$5,$6,$7,'ELIGIBLE',false,false,false, + 'ONE_TIME',false,$8, now(), now()) + RETURNING id`, + [b.ref, totalVgm, YARD[b.from], YARD[b.to], + isBulk ? PERISHABLE : null, b.note, b.type, COMPANY]); + id = res.rows[0].id; + } + + await client.query(`DELETE FROM freight.booking_container WHERE booking_id=$1`, [id]); + if (!isBulk) { + await client.query( + `INSERT INTO freight.booking_container + (booking_id, container_type_id, quantity, vgm_per_unit_tons, + total_vgm_tons, wagons_required, container_size, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,'40ft', now(), now())`, + [id, FT40, b.qty, vgmPerUnit, b.qty * vgmPerUnit, b.qty]); + } + out.push({ ref: b.ref, id, note: b.note }); + } + + console.table(out); + await client.end(); +} + +main().catch((e) => { console.error(e.message); process.exit(1); }); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index c5cc9a81e..a3ed3e417 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -1,5 +1,6 @@ import { Freight } from "@edr/types"; +import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "../bookings/entities/booking-wagon-cancellation.entity"; import { BillingService } from "./billing.service"; /** @@ -1033,3 +1034,73 @@ describe("BillingService.document", () => { expect(render).not.toHaveBeenCalled(); }); }); + +/** + * The pay-window guard belongs to the freight invoice. A wagon-cancellation fee + * rides source=booking but is raised on an already-PAID booking, so it inherits + * a deadline that has long passed — guarding it would make the fee permanently + * unsettleable. + */ +describe("BillingService.confirmOfflinePayment pay-window guard", () => { + const PAST = new Date(Date.now() - 86_400_000); + + function makeService(invoiceType: string) { + const invoice = { + id: "inv-1", + source: Freight.InvoiceSource.Booking, + sourceId: "booking-1", + type: invoiceType, + currency: "ETB", + status: Freight.InvoiceStatus.Issued, + balanceAmount: 500, + }; + const recordPayment = jest.fn().mockResolvedValue(invoice); + const dataSource = { + getRepository: () => ({ + findOne: async () => ({ id: "booking-1", paymentDeadline: PAST }), + }), + }; + const service = new BillingService( + dataSource as never, + { findById: async () => invoice } as never, + {} as never, + makeEvents() as never, + {} as never, + {} as never, + {} as never, + { upload: async () => ({ id: "file-1", name: "slip.pdf" }) } as never, + { get: () => undefined } as never, + { isEnabled: async () => true } as never, + ); + (service as unknown as { recordPayment: unknown }).recordPayment = + recordPayment; + return { service, recordPayment }; + } + + const slip = { originalname: "slip.pdf" } as never; + + it("refuses a freight invoice once the pay window has closed", async () => { + const { service } = makeService("PREPAID"); + await expect( + service.confirmOfflinePayment("inv-1", slip, {}), + ).rejects.toThrow(/payment window has closed/i); + }); + + it("settles a wagon-cancellation fee despite the closed window", async () => { + const { service, recordPayment } = makeService( + WAGON_CANCEL_FEE_INVOICE_TYPE, + ); + await service.confirmOfflinePayment("inv-1", slip, {}); + expect(recordPayment).toHaveBeenCalledWith( + "inv-1", + expect.objectContaining({ amount: 500, method: "BANK_TRANSFER" }), + ); + }); + + it("still requires the bank slip for a cancellation fee", async () => { + const { service } = makeService(WAGON_CANCEL_FEE_INVOICE_TYPE); + await expect( + service.confirmOfflinePayment("inv-1", undefined, {}), + ).rejects.toThrow(/slip file is required/i); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 7270c92b0..39c5a36ad 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -14,6 +14,7 @@ import { DataSource, EntityManager, In, SelectQueryBuilder } from "typeorm"; import { Booking } from "../bookings/entities/booking.entity"; import { AdditionalCharge } from "../bookings/entities/additional-charge.entity"; +import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "../bookings/entities/booking-wagon-cancellation.entity"; // Entity-only import (no module edge): portal reads resolve shipping-line // payers straight off the table. import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity"; @@ -710,7 +711,14 @@ export class BillingService { throw new BadRequestException("The bank payment slip file is required."); } - if (invoice.source === "booking") { + // The pay window belongs to the freight invoice. A wagon-cancellation fee + // rides source=booking but is raised on an ALREADY-PAID booking, so it + // inherits a deadline that has long passed — guarding it would make the fee + // permanently unsettleable. + if ( + invoice.source === "booking" && + invoice.type !== WAGON_CANCEL_FEE_INVOICE_TYPE + ) { const booking = await this.dataSource.getRepository(Booking).findOne({ where: { id: invoice.sourceId }, select: ["id", "paymentDeadline"], 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 387a20824..6736d7784 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 @@ -36,6 +36,7 @@ import { import { BookingsRepository } from './bookings.repository'; import { RebookCancelledWagonsDto, + RebookContainerLineDto, RequestWagonCancellationDto, } from './dto/wagon-cancellation.dto'; import { Booking } from './entities/booking.entity'; @@ -45,8 +46,11 @@ import { BookingWagonCancellation, CancelledQuantities, CancelledUnitSnapshot, + WAGON_CANCEL_FEE_INVOICE_TYPE, } from './entities/booking-wagon-cancellation.entity'; +export { WAGON_CANCEL_FEE_INVOICE_TYPE }; + /** * rates.rate_type of the cancellation fee — an existing rate-engine type * (trigger CANCELLATION, never auto-applied to booking pricing). Staff @@ -59,8 +63,6 @@ export const WAGON_CANCELLATION_FEE_RATE_TYPE = 'CANCELLATION_FEE'; /** `booking_container.container_size` is stored as "20ft"/"40ft" — `Number()` on it is NaN. */ const sizeFtOf = (size: string | number | null | undefined): number => parseInt(String(size ?? ''), 10); -/** invoices.type of the fee invoice — the settlement branch key in BookingInvoiceService. */ -export const WAGON_CANCEL_FEE_INVOICE_TYPE = 'WAGON_CANCEL_FEE'; const round2 = (n: number): number => Math.round(n * 100) / 100; const round3 = (n: number): number => Math.round(n * 1000) / 1000; @@ -767,7 +769,7 @@ export class BookingWagonCancellationService { ); } - const createDto = this.buildRebookDto(row, dto.scheduledDate); + const createDto = this.buildRebookDto(row, dto.scheduledDate, dto.containers); // Same currency as the source booking — the credit is in it. createDto.paymentCurrency = source.paymentCurrency ?? undefined; const created = await this.contractBooking.createUnderContract( @@ -1460,11 +1462,25 @@ export class BookingWagonCancellationService { private buildRebookDto( row: BookingWagonCancellation, scheduledDate: string, + overrides?: RebookContainerLineDto[], ): CreateBookingUnderContractDto { const dto: CreateBookingUnderContractDto = { scheduledDate }; const q = row.cancelledQuantities; if (q.bySize && Object.keys(q.bySize).length) { + // Unit overrides may rename containers, change seals and VGM — but the + // cancelled sizes and quantities are the contract of the credit: a size + // not on the credit, or a wrong unit count, is rejected. + const overrideBySize = new Map( + (overrides ?? []).map((o) => [o.containerSize, o.units]), + ); + for (const size of overrideBySize.keys()) { + if (!(size in q.bySize)) { + throw new BadRequestException( + `The credit has no ${size} containers — sizes and quantities must match the cancelled booking.`, + ); + } + } const units = q.units ?? []; dto.containers = Object.entries(q.bySize).map(([size, quantity]) => { const sized = units.filter((u) => u.containerSize === size); @@ -1473,13 +1489,23 @@ export class BookingWagonCancellationService { `Credit is missing unit snapshots for size ${size} (${sized.length}/${quantity}) — contact EDR support.`, ); } + const replacement = overrideBySize.get(size); + if (replacement && replacement.length !== quantity) { + throw new BadRequestException( + `The credit covers exactly ${quantity} × ${size} — you entered ${replacement.length}. Quantities cannot change on a rebook.`, + ); + } return { containerSize: size, quantity, - units: sized.map((u) => ({ - containerNumber: u.containerNumber, - sealNumber: u.sealNumber ?? undefined, - vgmTons: u.vgmTons, + // Hazardous/reefer flags always ride from the snapshot (the cargo is + // the same cargo); number/seal/VGM come from the override when given. + units: sized.map((u, i) => ({ + containerNumber: replacement?.[i]?.containerNumber ?? u.containerNumber, + sealNumber: replacement + ? (replacement[i]?.sealNumber ?? undefined) + : (u.sealNumber ?? undefined), + vgmTons: replacement?.[i]?.vgmTons ?? u.vgmTons, isHazardous: u.isHazardous, isReefer: u.isReefer, })), diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 6aa7eeb46..b6047633a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -52,6 +52,7 @@ import { FreightType, } from './entities/booking.entity'; import { Booking } from './entities/booking.entity'; +import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity'; import { BookingContainerAllocation } from './entities/booking-container-allocation.entity'; import { FileRecord } from '../files/entities/file.entity'; import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'; @@ -2262,16 +2263,25 @@ export class BookingsService { return this.findById(booking.id); } - /** Upload documents for a DRAFT booking. */ + /** + * Upload documents for a DRAFT booking — or for a booking created by + * rebooking a wagon-cancellation credit, whose paperwork may have changed + * with the new containers (old documents stay; new ones ride alongside). + */ async uploadDocuments( id: string, files: Express.Multer.File[], ): Promise { const booking = await this.findById(id); if (booking.status !== 'DRAFT') { - throw new BadRequestException( - 'Documents can only be uploaded for DRAFT bookings', - ); + const rebooked = await this.dataSource + .getRepository(BookingWagonCancellation) + .findOne({ where: { rebookedBookingId: id } }); + if (!rebooked) { + throw new BadRequestException( + 'Documents can only be uploaded for DRAFT bookings', + ); + } } await this.filesService.uploadMany(id, 'bookings', files); return this.findById(id); diff --git a/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts index 6a5dfe112..624762fac 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts @@ -67,10 +67,60 @@ export class RequestWagonCancellationDto { reason?: string; } +export class RebookUnitDto { + @ApiProperty({ description: 'Container number for the rebooked unit' }) + @IsString() + @MaxLength(64) + containerNumber!: string; + + @ApiPropertyOptional({ description: 'Seal number' }) + @IsOptional() + @IsString() + @MaxLength(64) + sealNumber?: string; + + @ApiPropertyOptional({ description: 'VGM (tons) of the unit' }) + @IsOptional() + @IsNumber() + @Min(0) + vgmTons?: number; +} + +export class RebookContainerLineDto { + @ApiProperty({ description: 'Container size as stored on the credit, e.g. "20ft"' }) + @IsString() + containerSize!: string; + + @ApiProperty({ + description: + 'The rebooked units for this size — count MUST equal the cancelled quantity', + type: [RebookUnitDto], + }) + @IsArray() + @ArrayNotEmpty() + @ValidateNested({ each: true }) + @Type(() => RebookUnitDto) + units!: RebookUnitDto[]; +} + export class RebookCancelledWagonsDto { @ApiProperty({ description: 'Shipment day the credit is rebooked onto (ISO date)' }) @IsDateString() scheduledDate!: string; + + @ApiPropertyOptional({ + description: + 'Optional unit overrides: container number / seal / VGM may change, but ' + + 'sizes and quantities must match the cancelled booking exactly. Sizes ' + + 'omitted here keep their original units.', + type: [RebookContainerLineDto], + }) + @IsOptional() + @IsArray() + @ArrayNotEmpty() + @ValidateNested({ each: true }) + @Type(() => RebookContainerLineDto) + containers?: RebookContainerLineDto[]; } export class FilterWagonCancellationsDto { diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-wagon-cancellation.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-wagon-cancellation.entity.ts index 723ea5d1c..950be4575 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-wagon-cancellation.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-wagon-cancellation.entity.ts @@ -5,6 +5,9 @@ import { Invoice } from '../../billing/entities/invoice.entity'; import { Rate } from '../../rule-engine/entities/rate.entity'; import { Booking } from './booking.entity'; +/** `invoices.type` of the wagon-cancellation fee invoice — the settlement branch key in BookingInvoiceService. */ +export const WAGON_CANCEL_FEE_INVOICE_TYPE = 'WAGON_CANCEL_FEE'; + export const WAGON_CANCELLATION_STATUSES = [ // Requested; fee invoice open; wagons still allocated to the customer. 'FEE_PENDING', diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 7e527da6e..beaaae384 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -8821,6 +8821,13 @@ export class TrainSchedulingService { allocatedWeightTons: roundTons(Number(allocation.allocatedWeightTons)), loadType: allocation.loadType ?? null, status: allocation.status, + // THIS load's own corridor, not the wagon's union span. + // A wagon reused across disjoint legs carries two loads + // with different yards; without these the leg board can + // only draw one merged bar and cannot say which load + // rides which leg. + originYardId: allocation.booking?.originYardId ?? null, + destinationYardId: allocation.booking?.destinationYardId ?? null, containerItems: (containerItemsByAllocation.get(allocation.id) ?? []).map( (item) => ({ id: item.id, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/fleet-plan.util.ts index 1dbd6e05a..d5763dfc8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/utils/fleet-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/fleet-plan.util.ts @@ -35,11 +35,34 @@ export type DeferredBookingRow = { shortage?: BookingWagonShortage | null; }; +/** A booking the customer has already paid for. */ +const isPaid = (booking: Booking): boolean => + booking.paymentStatus === 'PAID' || booking.status === 'PAID'; + +/** + * Seating order for the wagon planner. + * + * Government first, then PAID bookings, then priority score, then date. + * + * Payment ranks above priority score on purpose: money has changed hands and + * the customer was promised space on THIS train. Without it the planner + * seated an unpaid booking that merely arrived earlier and left a paid one + * with no wagon — the reported S-2026-00045 case, where a paid 695T bulk + * booking lost every wagon to unpaid container bookings and vanished from + * the train with free PW2 still standing in the consist. + * + * This only decides who is seated FIRST when the train is oversubscribed. It + * never invents capacity: an oversubscribed train still defers someone, and + * that someone is now the party who has not paid. + */ export function sortBookingsForScheduling(bookings: Booking[]): Booking[] { return [...bookings].sort((a, b) => { const govDiff = Number(Boolean(b.isGovernment)) - Number(Boolean(a.isGovernment)); if (govDiff !== 0) return govDiff; + const paidDiff = Number(isPaid(b)) - Number(isPaid(a)); + if (paidDiff !== 0) return paidDiff; + const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0); if (priorityDiff !== 0) return priorityDiff; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts index 275e0f9fb..2ed5625c6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -154,10 +154,46 @@ const shortageFor = ( ), ) : Math.max(1, containerWagonsForLines(booking.bookingContainers ?? [])); - const wagonsAvailable = candidates.reduce( - (sum, wt) => sum + availableOf(wt.id), - 0, - ); + + const freeByType = candidates.map((wt) => ({ wt, free: availableOf(wt.id) })); + const wagonsAvailable = freeByType.reduce((sum, c) => sum + c.free, 0); + + // PER_TON bulk: a bare wagon COUNT lies when the types carry different + // tonnage for this cargo. 14 NW5 (30T) + 10 PW2 (20T) is "24 wagons free" + // against a 24-wagon need, yet only 620T of the 695T booking fits — which + // is how a deferral could read "needs 24, 24 available (short 1)". Size the + // shortfall in the wagons the cargo's OWN caps require: how many more + // wagons of the best remaining type would carry the leftover tonnage. + const tons = bookingCargoTons(booking); + const perItem = + Number(booking.bulkTotalWeightTons ?? 0) > 0 && + Number(booking.cargoTotalWeightVgm ?? 0) > 0; + if (booking.freightType === 'BULK' && !perItem && tons > 0) { + let seatable = 0; + let usedWagons = 0; + for (const { wt, free } of freeByType) { + const perWagon = bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons)); + if (!(perWagon > 0) || free <= 0) continue; + seatable += free * perWagon; + usedWagons += free; + } + if (seatable < tons) { + const bestPerWagon = Math.max( + 1, + ...candidates.map((wt) => + bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons)), + ), + ); + return { + wagonTypeCodes: [...new Set(candidates.map((wt) => wt.code))].join('/'), + wagonsNeeded, + wagonsAvailable: usedWagons, + // Wagons of the best type still missing to carry the leftover tonnage. + wagonsShort: Math.max(1, Math.ceil((tons - seatable) / bestPerWagon)), + }; + } + } + return { wagonTypeCodes: [...new Set(candidates.map((wt) => wt.code))].join('/'), wagonsNeeded, diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegLoadBoardPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegLoadBoardPanel.tsx index 04b5e77de..e70905a8e 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegLoadBoardPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegLoadBoardPanel.tsx @@ -22,6 +22,15 @@ type Slot = NonNullable["wagons"][number]; type Stop = { yardId: string; label: string }; type Span = [number, number]; +/** The allocations of one slot that ride the same corridor — one drawn bar. */ +type SlotPart = { + slot: Slot; + span: Span; + loaded: boolean; + /** Allocations riding THIS span (all of the slot's when it is not split). */ + allocations: NonNullable; +}; + /** One physical wagon of the consist with every slot (leg load) pinned to it. */ interface WagonRow { key: string; @@ -30,12 +39,57 @@ interface WagonRow { position: number; typeCode: string | null; capacityTons: number; - slots: Array<{ slot: Slot; span: Span; loaded: boolean }>; + slots: SlotPart[]; } const round1 = (n: number) => Math.round(n * 10) / 10; const overlaps = (a: Span, b: Span) => a[0] < b[1] && b[0] < a[1]; +/** + * One drawn bar per corridor a slot actually serves. + * + * A wagon reused across disjoint legs (containers Doraleh→Dire Dawa, bulk + * Dire Dawa→Gelan) is ONE slot whose stored board/alight yards are the UNION + * of its loads. Drawing that union as a single bar claims both loads ride the + * whole way and hides where each one actually sits. Each allocation carries + * its own booking yards, so group by corridor and draw one bar per group — + * the board then reads "containers on leg 1, bulk on leg 2" truthfully. + * + * Falls back to the slot's own span whenever the yards are missing or not on + * the stop list, which is exactly the previous behaviour. + */ +function splitByCorridor(slot: Slot, slotSpan: Span, stops: Stop[]): SlotPart[] { + const allocations = slot.allocations ?? []; + const whole: SlotPart[] = [ + { slot, span: slotSpan, loaded: allocations.length > 0, allocations }, + ]; + if (allocations.length < 2) return whole; + + const idx = (yardId?: string | null) => + yardId ? stops.findIndex((s) => s.yardId === yardId) : -1; + const byCorridor = new Map(); + for (const allocation of allocations) { + const from = idx(allocation.originYardId); + const to = idx(allocation.destinationYardId); + // Any allocation without a usable corridor → keep the old single bar. + if (from < 0 || to <= from) return whole; + const key = `${from}-${to}`; + const entry = byCorridor.get(key); + if (entry) entry.allocations.push(allocation); + else byCorridor.set(key, { span: [from, to], allocations: [allocation] }); + } + if (byCorridor.size < 2) return whole; + + return [...byCorridor.values()] + .sort((a, b) => a.span[0] - b.span[0]) + .map((part) => ({ + slot, + span: part.span, + loaded: true, + allocations: part.allocations, + })); +} + /** * Leg board: rows = physical wagons in coupling order, columns = corridor legs * (A→B, B→C, …). A wagon reused on disjoint legs shows one load per leg on the @@ -86,11 +140,7 @@ export function LegLoadBoardPanel({ row.position = Math.min(row.position, slot.position ?? slot.sequenceNo); // Coupled-but-empty consist wagons carry no slot row: they are a target only. if (!slot.consistOnly) { - row.slots.push({ - slot, - span: spanOf(slot), - loaded: (slot.allocations?.length ?? 0) > 0, - }); + row.slots.push(...splitByCorridor(slot, spanOf(slot), stops)); } } return [...byKey.values()].sort((a, b) => a.position - b.position); @@ -211,15 +261,16 @@ export function LegLoadBoardPanel({ {rows.map((row) => { - const cargoTons = row.slots.reduce( - (s, x) => - s + - ((x.slot.allocations ?? []).reduce( - (a, al) => a + (al.allocatedWeightTons ?? 0), - 0, - ) || x.slot.assignedWeightTons || 0), - 0, - ); + // Heaviest single leg, not the sum of every bar: one slot may be + // drawn as several corridor bars, and a wagon reused on disjoint + // legs never carries both loads at once. Summing them reported a + // 60T wagon as 120T loaded and painted the capacity red. + const cargoTons = row.slots.reduce((max, part) => { + const tons = + part.allocations.reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0) || + (row.slots.length === 1 ? part.slot.assignedWeightTons || 0 : 0); + return Math.max(max, tons); + }, 0); const isPickedRow = picked?.rowKey === row.key; // A row can take the picked load when nothing loaded on it rides // any of the picked load's legs. @@ -279,12 +330,14 @@ export function LegLoadBoardPanel({ const isPicked = picked?.slotId === s.slot.id; const swappable = !!picked && !isPicked && !isPickedRow && s.loaded && canRearrange; - const allocs = s.slot.allocations ?? []; + // The allocations riding THIS bar's corridor — not the whole + // slot's, so a leg-shared wagon labels each leg with its own load. + const allocs = s.allocations; const bulk = allocs.some((a) => (a.loadType ?? "CONTAINER").toUpperCase() === "BULK"); const containers = allocs.flatMap((a) => a.containerItems ?? []); cells.push( void; } -const wagonItems = (wagon: Wagon) => - (wagon.allocations ?? []) - .flatMap((a) => a.containerItems ?? []) - .sort((a, b) => (a.positionOnWagon ?? 99) - (b.positionOnWagon ?? 99)); - const CONTAINER_GRADIENTS = [ "linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))", "linear-gradient(180deg, var(--mantine-color-blue-5), var(--mantine-color-blue-7))", @@ -192,7 +187,31 @@ function WagonCar({ }) { const [dropHover, setDropHover] = useState(false); const wagon = slots[0]!; - const loaded = slots.filter((s) => (s.allocations?.length ?? 0) > 0); + const loadedSlots = slots.filter((s) => (s.allocations?.length ?? 0) > 0); + // One drawn row per LOAD, not per slot. A wagon reused across disjoint legs + // (containers to Dire Dawa, bulk onward) is ONE slot holding two allocations + // with different corridors — counting slots drew that as a single row and + // hid the second load entirely. Group the slot's allocations by their own + // booking corridor so each load gets its own row, stacked top/bottom. + const loaded = loadedSlots.flatMap((slot) => { + const allocations = slot.allocations ?? []; + const byCorridor = new Map(); + for (const allocation of allocations) { + const key = + allocation.originYardId && allocation.destinationYardId + ? `${allocation.originYardId}->${allocation.destinationYardId}` + : "whole-route"; + byCorridor.set(key, [...(byCorridor.get(key) ?? []), allocation]); + } + if (byCorridor.size < 2) { + return [{ slot, allocations, corridorKey: null as string | null }]; + } + return [...byCorridor.entries()].map(([key, group]) => ({ + slot, + allocations: group, + corridorKey: key as string | null, + })); + }); const shared = loaded.length > 1; const isEmpty = !loaded.length; const isBulk = loaded.some((s) => @@ -201,12 +220,15 @@ function WagonCar({ // GROSS on both sides: cargo across every slot + tare (counted ONCE — the // slots share the same physical wagon) vs rated payload + tare. const tare = wagon.tareWeightTons ?? 0; + // Heaviest single load, not the sum: rows on disjoint legs never ride at the + // same time, so summing them would over-report what the wagon carries. const cargo = loaded.reduce( - (sum, s) => - sum + - ((s.allocations ?? []).reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0) || - s.assignedWeightTons || - 0), + (max, row) => + Math.max( + max, + row.allocations.reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0) || + (loaded.length === 1 ? row.slot.assignedWeightTons || 0 : 0), + ), 0, ); const assigned = cargo + tare; @@ -249,7 +271,7 @@ function WagonCar({ onSelectSlot(loaded[0] ?? wagon)} + onClick={() => onSelectSlot(loaded[0]?.slot ?? wagon)} style={{ width: 148, flexShrink: 0, cursor: "pointer" }} > - {loaded.map((slot, r) => { - const rowBulk = (slot.allocations ?? []).some((a) => + {loaded.map((row, r) => { + const slot = row.slot; + const rowBulk = row.allocations.some((a) => (a.loadType ?? "").toUpperCase().includes("BULK"), ); - const rowBlocks = wagonItems(slot).slice(0, 2); + // Container blocks of THIS row's allocations only, so a + // leg-shared wagon shows each leg's own boxes. + const rowBlocks = row.allocations + .flatMap((a) => a.containerItems ?? []) + .slice() + .sort( + (a, b) => (a.positionOnWagon ?? 0) - (b.positionOnWagon ?? 0), + ) + .slice(0, 2); const rowSelected = shared && slot.id === selectedWagonId; const rowHeight = shared ? 20 : 26; return ( ) : ( - {loaded.map((slot) => { - const slotAllocation = slot.allocations?.[0]; + {loaded.map((row) => { + const slot = row.slot; + const slotAllocation = row.allocations[0]; const slotCompany = getCompany(slotAllocation?.bookingId); - const slotContainers = wagonItems(slot).map( - (c) => c.containerNumber?.trim() || "—", - ); + // This row's own containers, so a leg-shared wagon lists each + // leg's boxes under its own load rather than all of them twice. + const slotContainers = row.allocations + .flatMap((a) => a.containerItems ?? []) + .map((c) => c.containerNumber?.trim() || "—"); return ( ; + units?: Array<{ + containerSize: string; + containerNumber: string; + sealNumber?: string | null; + vgmTons: number; + }>; + }; +} + +/** Editable rebook unit — prefilled from the cancelled snapshot. */ +interface RebookUnitDraft { + containerSize: string; + containerNumber: string; + sealNumber: string; + vgmTons: number | ""; } interface WagonCancellationListResponse { @@ -116,6 +138,49 @@ export default function WagonCancellationsPage() { const [from, setFrom] = useState(null); const [to, setTo] = useState(null); const [voiding, setVoiding] = useState(null); + // GL rebook of a customs (Path B) credit: pick the day; container number / + // seal / VGM may be corrected. Non-customs credits are rebooked by the + // customer from the portal. + const canRebook = hasPermission( + user, + FREIGHT_PERMS.bookings.wagonCancellationRebook, + ); + const [rebooking, setRebooking] = useState(null); + const [rebookDate, setRebookDate] = useState(null); + const [rebookDrafts, setRebookDrafts] = useState([]); + const openRebook = (r: WagonCancellation) => { + setRebooking(r); + setRebookDate(null); + setRebookDrafts( + (r.cancelledQuantities?.units ?? []).map((u) => ({ + containerSize: u.containerSize, + containerNumber: u.containerNumber, + sealNumber: u.sealNumber ?? "", + vgmTons: Number(u.vgmTons) || "", + })), + ); + }; + const rebookContainersPayload = () => { + const bySize = new Map(); + for (const d of rebookDrafts) { + bySize.set(d.containerSize, [...(bySize.get(d.containerSize) ?? []), d]); + } + return [...bySize.entries()].map(([containerSize, units]) => ({ + containerSize, + units: units.map((u) => ({ + containerNumber: u.containerNumber.trim(), + ...(u.sealNumber.trim() ? { sealNumber: u.sealNumber.trim() } : {}), + ...(u.vgmTons !== "" ? { vgmTons: Number(u.vgmTons) } : {}), + })), + })); + }; + const rebook = useMutation({ + mutationFn: () => + api.post(`/bookings/wagon-cancellations/${rebooking!.id}/rebook`, { + scheduledDate: toDayString(rebookDate!), + ...(rebookDrafts.length ? { containers: rebookContainersPayload() } : {}), + }), + }); const resetPage = () => setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); @@ -234,18 +299,39 @@ export default function WagonCancellationsPage() { header: () => , cell: ({ row }) => { const r = row.original; - if (r.status !== "FEE_PENDING" || !canVoid) return null; + const showVoid = r.status === "FEE_PENDING" && canVoid; + // Customs credits are GL's to rebook; non-customs ones the customer + // rebooks from the portal. + const showRebook = + r.status === "CREDIT_AVAILABLE" && + canRebook && + Boolean(r.booking?.customsClearingEnabled) && + Number(r.creditAmount) > 0; + if (!showVoid && !showRebook) return null; return ( - + {showRebook && ( + + )} + {showVoid && ( + + )} ); }, @@ -391,6 +477,117 @@ export default function WagonCancellationsPage() { )} + setRebooking(null)} + title="Rebook cancelled wagons" + centered + radius="md" + > + {rebooking && ( + + + {rebooking.booking?.reference ?? rebooking.bookingId} ·{" "} + {rebooking.wagonsCancelled} wagon(s) · credit{" "} + {formatMoney(rebooking.creditAmount, rebooking.feeCurrency, 2)} + + setRebookDate(v ? new Date(v) : null)} + radius="md" + /> + {rebookDrafts.length > 0 && ( + + + Correct the container details if they changed — sizes and + quantities stay as cancelled. + + {rebookDrafts.map((d, i) => ( + + { + const v = e.currentTarget.value; + setRebookDrafts((prev) => + prev.map((x, idx) => + idx === i ? { ...x, containerNumber: v } : x, + ), + ); + }} + size="xs" + radius="md" + style={{ flex: 1.4 }} + /> + { + const v = e.currentTarget.value; + setRebookDrafts((prev) => + prev.map((x, idx) => + idx === i ? { ...x, sealNumber: v } : x, + ), + ); + }} + size="xs" + radius="md" + style={{ flex: 1 }} + /> + { + const raw = e.currentTarget.value; + setRebookDrafts((prev) => + prev.map((x, idx) => + idx === i + ? { ...x, vgmTons: raw === "" ? "" : Number(raw) } + : x, + ), + ); + }} + size="xs" + radius="md" + style={{ width: 90 }} + /> + + ))} + + )} + + + + + + )} + ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx index 5b4da0f43..b2957a7b4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx @@ -95,12 +95,17 @@ function PayWindowCell({ deadline }: { deadline: string | null }) { ); } +/** `invoices.type` of a wagon-cancellation fee — mirrors the API constant. */ +const WAGON_CANCEL_FEE_INVOICE_TYPE = "WAGON_CANCEL_FEE"; + /** * "Confirm paid" for one row. Booking invoices are only confirmable while the * booking's pay window is open (the API refuses otherwise): no window yet → * no button; window closed → button disabled with the reason, and it flips * live the second the countdown hits zero. Non-booking invoices (warehouse, - * clearance…) have no window and stay confirmable. + * clearance…) have no window and stay confirmable — and so do + * wagon-cancellation fees, which ride source=booking but are raised on an + * already-paid booking whose window has closed. */ function ConfirmCell({ row, @@ -109,10 +114,11 @@ function ConfirmCell({ row: OfflineUsdInvoice; onConfirm: (row: OfflineUsdInvoice) => void; }) { - const deadline = row.booking?.paymentDeadline ?? null; + const feeInvoice = row.type === WAGON_CANCEL_FEE_INVOICE_TYPE; + const deadline = feeInvoice ? null : (row.booking?.paymentDeadline ?? null); const now = useNow(deadline); - if (row.booking && !deadline) return null; + if (row.booking && !deadline && !feeInvoice) return null; const closed = Boolean(deadline && new Date(deadline).getTime() <= now); return ( diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 2330dc4b9..30abfa419 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -556,6 +556,13 @@ export interface TrainScheduleWagonAllocation { allocatedWeightTons: number; loadType?: string | null; status?: string; + /** + * This load's OWN corridor. A wagon reused across disjoint legs carries two + * loads with different yards, so the wagon's boardYardId/alightYardId (their + * union) cannot say which load rides which leg — these can. + */ + originYardId?: string | null; + destinationYardId?: string | null; containerItems?: Array<{ id: string; containerNumber: string | null; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonCancellationCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonCancellationCard.tsx index abd3cce4f..aa75f0904 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonCancellationCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonCancellationCard.tsx @@ -26,6 +26,12 @@ import { type WagonCancellationPreview, } from "@/services/bookings.service"; import { OperationDatePicker } from "@/pages/bookings/clearance"; +import { + RebookUnitsEditor, + containersFromDrafts, + draftsFromSnapshot, + type RebookUnitDraft, +} from "@/pages/bookings/RebookUnitsEditor"; import { useFeeInvoicePayment } from "@/pages/bookings/payments/useBookingPayment"; import type { BookingDetail } from "../booking-detail-types"; @@ -213,10 +219,18 @@ export function WagonCancellationCard({ }); const [rebookDate, setRebookDate] = useState(""); + // Non-customs: container number / seal / VGM may change at rebook. Customs + // (Path B) credits are rebooked by GL from the backoffice instead. + const isCustoms = Boolean(booking.customsClearingEnabled); + const [rebookDrafts, setRebookDrafts] = useState(null); + const snapshotUnits = creditRow?.cancelledQuantities?.units ?? []; + const drafts = rebookDrafts ?? draftsFromSnapshot(snapshotUnits); + const showUnitEditor = !isCustoms && drafts.length > 0; const rebookMutation = useMutation({ mutationFn: () => bookingsService.rebookWagonCancellation(creditRow!.id, { scheduledDate: rebookDate, + ...(showUnitEditor ? { containers: containersFromDrafts(drafts) } : {}), }), onSuccess: ({ bookingId }) => { toast.success("Wagons rebooked — taking you to the new booking.", { @@ -290,22 +304,34 @@ export function WagonCancellationCard({ is available. Pick a shipment day to rebook them as a new paid booking (no further payment needed). - - - - + {isCustoms ? ( + + This is a customs-cleared booking — Global Logistics will rebook + the credit for you. + + ) : ( + <> + + {showUnitEditor && ( + + )} + + + + + )} ) : ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx index 9652a068e..e4c02608f 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx @@ -194,11 +194,13 @@ function PrimaryAction({ const { status, id } = booking; const go = () => onNavigate(`/bookings/${id}`); // Cancelled wagons with a paid credit (partial or whole cancel) → rebook. - if (credit) { + // Customs (Path B) credits are rebooked by GL from the backoffice, not here. + if (credit && !booking.customsClearingEnabled) { return ( ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/RebookUnitsEditor.tsx b/apps/edr-freight-web/portal/src/pages/bookings/RebookUnitsEditor.tsx new file mode 100644 index 000000000..7c444ae24 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/RebookUnitsEditor.tsx @@ -0,0 +1,99 @@ +import { Box, Group, NumberInput, Text, TextInput } from "@mantine/core"; + +/** One editable rebook unit — prefilled from the cancelled snapshot. */ +export interface RebookUnitDraft { + containerSize: string; + containerNumber: string; + sealNumber: string; + vgmTons: number | ""; +} + +/** Snapshot units → editable drafts (the initial editor state). */ +export function draftsFromSnapshot( + units: Array<{ + containerSize: string; + containerNumber: string; + sealNumber?: string | null; + vgmTons: number; + }>, +): RebookUnitDraft[] { + return units.map((u) => ({ + containerSize: u.containerSize, + containerNumber: u.containerNumber, + sealNumber: u.sealNumber ?? "", + vgmTons: Number(u.vgmTons) || "", + })); +} + +/** Drafts → the rebook payload's containers field (grouped by size). */ +export function containersFromDrafts(drafts: RebookUnitDraft[]) { + const bySize = new Map(); + for (const d of drafts) { + bySize.set(d.containerSize, [...(bySize.get(d.containerSize) ?? []), d]); + } + return [...bySize.entries()].map(([containerSize, units]) => ({ + containerSize, + units: units.map((u) => ({ + containerNumber: u.containerNumber.trim(), + ...(u.sealNumber.trim() ? { sealNumber: u.sealNumber.trim() } : {}), + ...(u.vgmTons !== "" ? { vgmTons: Number(u.vgmTons) } : {}), + })), + })); +} + +/** + * Per-unit editor for a rebook: container number, seal and VGM may change; + * sizes and quantities are fixed by the credit, so rows can't be added or + * removed. + */ +export function RebookUnitsEditor({ + drafts, + onChange, +}: { + drafts: RebookUnitDraft[]; + onChange: (next: RebookUnitDraft[]) => void; +}) { + const set = (i: number, patch: Partial) => + onChange(drafts.map((d, idx) => (idx === i ? { ...d, ...patch } : d))); + + return ( + + + Update the container details if they changed — the sizes and quantities + stay as cancelled. + + {drafts.map((d, i) => ( + + set(i, { containerNumber: e.currentTarget.value })} + radius={8} + size="xs" + style={{ flex: 1.4 }} + /> + set(i, { sealNumber: e.currentTarget.value })} + radius={8} + size="xs" + style={{ flex: 1 }} + /> + set(i, { vgmTons: typeof v === "number" ? v : "" })} + min={0} + radius={8} + size="xs" + style={{ width: 90 }} + /> + + ))} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/RebookWagonsButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/RebookWagonsButton.tsx index 372d24156..44ce6178f 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/RebookWagonsButton.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/RebookWagonsButton.tsx @@ -9,6 +9,12 @@ import { api } from "@/services/api"; import { bookingsService, type WagonCancellation } from "@/services/bookings.service"; import { OperationDatePicker } from "./clearance"; import { formatAmount } from "./BookingDetailPage/utils"; +import { + RebookUnitsEditor, + containersFromDrafts, + draftsFromSnapshot, + type RebookUnitDraft, +} from "./RebookUnitsEditor"; const apiErrorMessage = (error: unknown, fallback: string) => { const data = (error as { response?: { data?: { message?: string | string[] } } }) @@ -26,19 +32,30 @@ export function RebookWagonsButton({ cancellation, currency, size = "xs", + editableUnits, }: { cancellation: WagonCancellation; currency?: string; size?: "xs" | "sm"; + /** Non-customs contracts: container number / seal / VGM may be edited at rebook. */ + editableUnits?: boolean; }) { const navigate = useNavigate(); const qc = useQueryClient(); const [open, setOpen] = useState(false); const [date, setDate] = useState(""); + const snapshotUnits = cancellation.cancelledQuantities?.units ?? []; + const [drafts, setDrafts] = useState(() => + draftsFromSnapshot(snapshotUnits), + ); + const showEditor = Boolean(editableUnits) && drafts.length > 0; const rebook = useMutation({ mutationFn: () => - bookingsService.rebookWagonCancellation(cancellation.id, { scheduledDate: date }), + bookingsService.rebookWagonCancellation(cancellation.id, { + scheduledDate: date, + ...(showEditor ? { containers: containersFromDrafts(drafts) } : {}), + }), onSuccess: ({ bookingId }) => { qc.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); qc.invalidateQueries({ queryKey: api.bookings.listMyWagonCancellations.queryKey() }); @@ -87,6 +104,9 @@ export function RebookWagonsButton({ value={date} onChange={setDate} /> + {showEditor && ( + + )}