/** * 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); });