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