/** * Runs the REAL planner (planWagonsWithStock) against the WGT-* bookings and * the REAL wagon stock standing in edr_dev. Read-only: it plans, asserts, and * reports — it writes nothing. * * npx ts-node -T scripts/run-wagon-gate-tests.ts */ import { DataSource } from 'typeorm'; import { planWagonsWithStock } from '../src/modules/train-scheduling/wagon-plan-flex.util'; import type { AllowedWagonTypeMap, 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', KALITY: '61ae1e66-c229-4dcd-9851-b2b9424f3a95', }; 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 results: Array<{ testCase: string; checks: Check[] }> = []; const check = (list: Check[], name: string, pass: boolean, detail: string) => { list.push({ name, pass, detail }); }; /** Wagon types keyed by id, and the allowed-type map read from the join tables. */ async function loadConfig(): Promise<{ allowed: AllowedWagonTypeMap; byId: Map; 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 codes = new Map(types.map((t) => [t.id, t.code])); const cargoRows: Array<{ typeId: string; wagonTypeId: string }> = await ds.query( `SELECT cargo_type_id AS "typeId", wagon_type_id AS "wagonTypeId" FROM freight.cargo_type_wagon_types`, ); const containerRows: Array<{ typeId: string; wagonTypeId: string }> = await ds.query( `SELECT container_type_id AS "typeId", wagon_type_id AS "wagonTypeId" FROM freight.container_type_wagon_types`, ); const collect = (rows: Array<{ typeId: string; wagonTypeId: string }>) => { const map = new Map(); for (const row of rows) { const wt = byId.get(row.wagonTypeId); if (!wt) continue; map.set(row.typeId, [...(map.get(row.typeId) ?? []), wt]); } return map; }; return { allowed: { byCargoTypeId: collect(cargoRows), byContainerTypeId: collect(containerRows), }, byId, codes, }; } /** The WGT-* bookings, hydrated the way the planner expects them. */ async function loadBookings(refs: string[]): 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.created_at AS "createdAt" FROM freight.bookings b WHERE b.reference = ANY($1) AND b.deleted_at IS NULL`, [refs], ); const cargoTypes = await ds.query( `SELECT ct.id, ct.cargo_type_name AS "cargoTypeName", ct.code, ct.unit_of_measure AS "unitOfMeasure", ct.tons_per_wagon_map AS "tonsPerWagonMap", ct.items_per_wagon_map AS "itemsPerWagonMap" FROM freight.cargo_types ct`, ); const cargoById = new Map(cargoTypes.map((c: any) => [c.id, c])); const { allowed } = await loadConfig(); 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", bc.container_number AS "containerNumber", 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 rows.map((r: any) => { const cargoType = r.cargoTypeId ? cargoById.get(r.cargoTypeId) : null; return { ...r, cargoType: cargoType ? { ...cargoType, 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; }); } /** Real AVAILABLE wagons standing at a yard, by type. */ async function stockAtYard(yardId: string, codes: Map): Promise { const rows = await ds.query( `SELECT wagon_type_id AS "wagonTypeId", count(*)::int AS n FROM freight.wagons WHERE current_yard_id = $1 AND deleted_at IS NULL AND status = 'AVAILABLE' AND train_id IS NULL AND current_train_schedule_id IS NULL GROUP BY wagon_type_id`, [yardId], ); return { mode: 'YARD', remainingByTypeId: new Map(rows.map((r: any) => [r.wagonTypeId, r.n])), codesByTypeId: codes, }; } /** Per-type slot counts of a plan, as a readable "10x PW2, 17x NW5". */ const planByType = (plan: any[]): string => { const counts = new Map(); for (const slot of plan) { counts.set(slot.wagonTypeCode, (counts.get(slot.wagonTypeCode) ?? 0) + 1); } return [...counts.entries()].map(([code, n]) => `${n}x ${code}`).join(', ') || 'none'; }; async function main() { await ds.initialize(); const { allowed, codes } = await loadConfig(); const dctStock = await stockAtYard(YARD.DCT, codes); console.log('\n=== REAL FLEET AT DORALEH_FREEZONE (AVAILABLE, unpinned) ==='); console.table( [...dctStock.remainingByTypeId.entries()].map(([id, n]) => ({ wagonType: codes.get(id), available: n, })), ); // ---------------------------------------------------------------- CASE A { const checks: Check[] = []; const bookings = await loadBookings(['WGT-A1', 'WGT-A2', 'WGT-A3']); // Constrain the fleet to make the contest real: 20 NW5 + 10 PW2. const stock: WagonStock = { mode: 'TRAIN', remainingByTypeId: new Map([ ['8f717b09-eec1-46ad-be3d-2dc0a56e55e7', 20], // NW5 ['8eec3a7d-8482-4397-96b6-59a028210722', 10], // PW2 ]), codesByTypeId: codes, }; const result = planWagonsWithStock({ bookings, allowed, stock }); const bulkSlots = result.plan.filter((s) => s.slotLoadType === 'BULK'); const pw2Bulk = bulkSlots.filter((s) => s.wagonTypeCode === 'PW2').length; const nw5Bulk = bulkSlots.filter((s) => s.wagonTypeCode === 'NW5').length; check(checks, 'bulk fills bulk-only PW2 first', pw2Bulk === 10, `${pw2Bulk}/10 PW2 used by bulk`); check(checks, 'PW2 bulk wagons respect the 20T cargo cap', bulkSlots.filter((s) => s.wagonTypeCode === 'PW2') .every((s) => s.assignedWeightTons <= 20), `max PW2 load ${Math.max(0, ...bulkSlots.filter((s) => s.wagonTypeCode === 'PW2').map((s) => s.assignedWeightTons))}T (cap 20T)`); check(checks, 'NW5 bulk wagons respect the 30T cargo cap', bulkSlots.filter((s) => s.wagonTypeCode === 'NW5') .every((s) => s.assignedWeightTons <= 30), `max NW5 bulk load ${Math.max(0, ...bulkSlots.filter((s) => s.wagonTypeCode === 'NW5').map((s) => s.assignedWeightTons))}T (cap 30T)`); check(checks, 'no wagon mixes bulk with other cargo', validateWagonCargoExclusivity(result.plan).length === 0, validateWagonCargoExclusivity(result.plan).join('; ') || 'clean'); check(checks, 'plan is honest about who fits', result.fitting.length + result.deferred.length === 3, `fitting=[${result.fitting.map((b) => b.reference)}] deferred=[${result.deferred.map((d) => d.reference)}]`); results.push({ testCase: 'A — bulk vs container contest for NW5 (20 NW5 + 10 PW2)', checks }); console.log('\n=== CASE A: 695T Perishable + 12x40ft + 16x20ft, stock 20 NW5 / 10 PW2 ==='); console.log(`plan: ${result.plan.length} wagons — ${planByType(result.plan)}`); console.log(` bulk: ${pw2Bulk}x PW2 + ${nw5Bulk}x NW5`); console.log(`fitting: ${result.fitting.map((b) => b.reference).join(', ') || 'none'}`); for (const d of result.deferred) console.log(`deferred: ${d.reference} — ${d.reason}`); } // ---------------------------------------------------------------- CASE B { const checks: Check[] = []; const bookings = await loadBookings(['WGT-B1', 'WGT-B2']); const stops = [YARD.DCT, YARD.DIRE, YARD.KALITY]; const legs = new Map( bookings.map((b: any) => [ b.id, { from: stops.indexOf(b.originYardId), to: stops.indexOf(b.destinationYardId) }, ]), ); const stock: WagonStock = { mode: 'TRAIN', remainingByTypeId: new Map([ ['8f717b09-eec1-46ad-be3d-2dc0a56e55e7', 3], // NW5 — only 3, forces reuse ['8eec3a7d-8482-4397-96b6-59a028210722', 0], ]), codesByTypeId: codes, }; const result = planWagonsWithStock({ bookings, allowed, stock, legs, edgeCount: 2, stops, }); check(checks, 'both disjoint-leg bookings fit on 3 wagons', result.fitting.length === 2 && result.plan.length <= 3, `fitting=${result.fitting.length}/2, wagons=${result.plan.length}`); const legIssues = validateWagonCargoExclusivity(result.plan, legs, 2); check(checks, 'no wagon carries bulk + container on the SAME leg', legIssues.length === 0, legIssues.join('; ') || 'clean'); // …and the leg-blind reading WOULD flag it, proving the reuse is real. check(checks, 'the same wagon does carry both kinds across DIFFERENT legs', validateWagonCargoExclusivity(result.plan).length > 0, 'leg-blind check sees bulk+container on one wagon (legal: disjoint legs)'); const reused = result.plan.filter((s) => s.allocations.length > 1); check(checks, 'leg-disjoint reuse actually happens', reused.length > 0, `${reused.length} wagon(s) carry both bookings on different legs`); results.push({ testCase: 'B — leg-disjoint reuse (container leg 1, bulk leg 2), only 3 NW5', checks }); console.log('\n=== CASE B: 6x20ft DCT->Dire + 120T bulk Dire->Kality, only 3 NW5 ==='); console.log(`plan: ${result.plan.length} wagons — ${planByType(result.plan)}`); for (const slot of result.plan) { console.log(` wagon #${slot.sequenceNo} (${slot.wagonTypeCode}): ${slot.allocations .map((a) => `${a.bookingReference}/${a.loadType} ${a.allocatedWeightTons}T`).join(' + ')}`); } for (const d of result.deferred) console.log(`deferred: ${d.reference} — ${d.reason}`); } // ---------------------------------------------------------------- CASE C { const checks: Check[] = []; const bookings = await loadBookings(['WGT-C1']); const stock: WagonStock = { mode: 'TRAIN', remainingByTypeId: new Map([ ['8f717b09-eec1-46ad-be3d-2dc0a56e55e7', 20], // NW5 (40T cap for steel) ['8eec3a7d-8482-4397-96b6-59a028210722', 20], // PW2 (10T cap for steel!) ]), codesByTypeId: codes, }; const result = planWagonsWithStock({ bookings, allowed, stock }); const nw5 = result.plan.filter((s) => s.wagonTypeCode === 'NW5').length; const pw2 = result.plan.filter((s) => s.wagonTypeCode === 'PW2').length; check(checks, 'inverted caps: uses the 40T NW5, not the 10T PW2', nw5 === 10 && pw2 === 0, `${nw5}x NW5 (40T cap) + ${pw2}x PW2 (10T cap) for 400T`); check(checks, 'wagon count matches the cap math (400/40 = 10)', result.plan.length === 10, `${result.plan.length} wagons`); results.push({ testCase: 'C — cap inversion (Steel Billet: NW5 40T vs PW2 10T)', checks }); console.log('\n=== CASE C: 400T Steel Billet, caps NW5=40T PW2=10T, both in stock ==='); console.log(`plan: ${result.plan.length} wagons — ${planByType(result.plan)}`); } // ---------------------------------------------------------------- CASE D { const checks: Check[] = []; const bookings = await loadBookings(['WGT-D1', 'WGT-D2']); const stock = await stockAtYard(YARD.DCT, codes); const result = planWagonsWithStock({ bookings, allowed, stock }); const d1 = result.plan.filter((s) => s.allocations.some((a) => a.bookingReference === 'WGT-D1')); const d2 = result.plan.filter((s) => s.allocations.some((a) => a.bookingReference === 'WGT-D2')); check(checks, 'Beans (PW2-only) is deferred — no PW2 free at DCT', result.deferred.some((d) => d.reference === 'WGT-D1'), result.deferred.find((d) => d.reference === 'WGT-D1')?.reason ?? `planned on ${planByType(d1)}`); check(checks, 'Sand rides only its configured CW3/CW4', d2.length > 0 && d2.every((s) => ['CW3', 'CW4'].includes(s.wagonTypeCode)), `Sand on ${planByType(d2)}`); results.push({ testCase: 'D — exclusive-type cargo against REAL DCT stock', checks }); console.log('\n=== CASE D: 200T Beans (PW2-only) + 300T Sand (CW3/CW4-only), REAL stock ==='); console.log(`plan: ${result.plan.length} wagons — ${planByType(result.plan)}`); for (const d of result.deferred) console.log(`deferred: ${d.reference} — ${d.reason}`); } // ---------------------------------------------------------------- CASE E { const checks: Check[] = []; const bookings = await loadBookings(['WGT-E1', 'WGT-E2']); const stock: WagonStock = { mode: 'TRAIN', remainingByTypeId: new Map([ ['8f717b09-eec1-46ad-be3d-2dc0a56e55e7', 30], ['8eec3a7d-8482-4397-96b6-59a028210722', 5], ]), codesByTypeId: codes, }; const result = planWagonsWithStock({ bookings, allowed, stock }); check(checks, '3000T booking is deferred, not silently truncated', result.deferred.some((d) => d.reference === 'WGT-E1'), result.deferred.find((d) => d.reference === 'WGT-E1')?.reason ?? 'FITTED (unexpected)'); const e2 = result.plan.filter((s) => s.allocations.some((a) => a.bookingReference === 'WGT-E2')); check(checks, 'the 45T booking never shares a wagon with another booking', e2.every((s) => new Set(s.allocations.map((a) => a.bookingId)).size === 1), `${e2.length} wagon(s), all single-booking`); check(checks, 'exclusivity holds across the whole plan', validateWagonCargoExclusivity(result.plan).length === 0, validateWagonCargoExclusivity(result.plan).join('; ') || 'clean'); results.push({ testCase: 'E — overload one leg (3000T) + small bulk beside it', checks }); console.log('\n=== CASE E: 3000T + 45T Perishable, stock 30 NW5 / 5 PW2 ==='); console.log(`plan: ${result.plan.length} wagons — ${planByType(result.plan)}`); console.log(`fitting: ${result.fitting.map((b) => b.reference).join(', ') || 'none'}`); for (const d of result.deferred) console.log(`deferred: ${d.reference} — ${d.reason}`); } // ---------------------------------------------------------------- REPORT console.log('\n\n================ TEST REPORT ================'); let passed = 0; let failed = 0; for (const group of results) { console.log(`\n${group.testCase}`); for (const c of group.checks) { console.log(` ${c.pass ? 'PASS' : 'FAIL'} ${c.name}\n ${c.detail}`); c.pass ? (passed += 1) : (failed += 1); } } console.log(`\n---------------------------------------------`); console.log(`TOTAL: ${passed} passed, ${failed} failed`); await ds.destroy(); process.exit(failed > 0 ? 1 : 0); } main().catch((err) => { console.error(err); process.exit(1); });