diff --git a/apps/edr-freight-api/scripts/run-wagon-gate-tests.ts b/apps/edr-freight-api/scripts/run-wagon-gate-tests.ts new file mode 100644 index 000000000..46b752373 --- /dev/null +++ b/apps/edr-freight-api/scripts/run-wagon-gate-tests.ts @@ -0,0 +1,362 @@ +/** + * 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); +}); diff --git a/apps/edr-freight-api/scripts/seed-wagon-gate-testcases.cjs b/apps/edr-freight-api/scripts/seed-wagon-gate-testcases.cjs new file mode 100644 index 000000000..e37dc6536 --- /dev/null +++ b/apps/edr-freight-api/scripts/seed-wagon-gate-testcases.cjs @@ -0,0 +1,142 @@ +/** + * Seeds the wagon-allocation test bookings into edr_dev (idempotent) and + * prints their ids. Rows are kept — they are the evidence for the report. + * + * References are prefixed WGT- (Wagon Gate Test) so they are easy to find: + * SELECT * FROM freight.bookings WHERE reference LIKE 'WGT-%'; + */ +const { Client } = require('pg'); + +const YARD = { + DORALEH_FREEZONE: 'fc558b95-da28-4fc3-8348-311a290c34ae', + DIRE_DAWA: 'f7b1686f-d43e-42aa-bc6a-d3d5849296c9', + KALITY: '61ae1e66-c229-4dcd-9851-b2b9424f3a95', + DMP: '7b658678-ce2b-41aa-8dc5-b38ba4a76e9b', +}; +const CARGO = { + PERISHABLE: 'a5991d3a-d690-4b7e-98fd-ea3333aa16e7', // NW5 30T / PW2 20T + STEEL_BILLET: '8291ccc3-0dd9-4d78-aa44-284d028a19ce', // NW5 40T / PW2 10T (inverted) + BEANS: '9afd8eb3-975b-4ea7-a7db-04eb2c20a04d', // PW2 only, no cap + SAND: '8942884d-9991-42bb-87f7-a70930f8c43c', // CW3/CW4 only, no cap +}; +const CONTAINER = { + '20FT': '77cf24ec-e74b-4bdb-b1cc-6f336379cc58', + '40FT': '349072e7-8a90-4c03-b682-08976abfd7e8', +}; +const COMPANY = '300d5510-e3a5-4858-bc28-6e3beda8ca80'; + +/** Test bookings: 4 corridors, bulk + container, contested and uncontested. */ +const BOOKINGS = [ + // --- Case A: DORALEH_FREEZONE -> KALITY (full leg), bulk vs container contest + { ref: 'WGT-A1', dir: 'IMPORT', type: 'BULK', from: 'DORALEH_FREEZONE', to: 'KALITY', + cargo: 'PERISHABLE', tons: 695, note: 'A: 695T Perishable, contends with containers for NW5' }, + { ref: 'WGT-A2', dir: 'IMPORT', type: 'CONTAINER', from: 'DORALEH_FREEZONE', to: 'KALITY', + containers: [{ type: '40FT', qty: 12, vgm: 26 }], note: 'A: 12x40ft, needs 12 container wagons' }, + { ref: 'WGT-A3', dir: 'IMPORT', type: 'CONTAINER', from: 'DORALEH_FREEZONE', to: 'KALITY', + containers: [{ type: '20FT', qty: 16, vgm: 12 }], note: 'A: 16x20ft = 8 wagons (TEU paired)' }, + + // --- Case B: sub-corridor legs on the same 3-stop route (leg overlap) + { ref: 'WGT-B1', dir: 'IMPORT', type: 'CONTAINER', from: 'DORALEH_FREEZONE', to: 'DIRE_DAWA', + containers: [{ type: '20FT', qty: 6, vgm: 14 }], note: 'B: leg 1 only (DCT->Dire), 3 wagons' }, + { ref: 'WGT-B2', dir: 'IMPORT', type: 'BULK', from: 'DIRE_DAWA', to: 'KALITY', + cargo: 'PERISHABLE', tons: 120, note: 'B: leg 2 only (Dire->Kality) - may reuse leg-1 wagons' }, + + // --- Case C: cap inversion - Steel Billet is CHEAPER on NW5 (40T) than PW2 (10T) + { ref: 'WGT-C1', dir: 'IMPORT', type: 'BULK', from: 'DORALEH_FREEZONE', to: 'KALITY', + cargo: 'STEEL_BILLET', tons: 400, note: 'C: inverted caps - must NOT blindly take PW2' }, + + // --- Case D: exclusive-type cargo (Beans=PW2 only, Sand=CW3/CW4 only) + { ref: 'WGT-D1', dir: 'IMPORT', type: 'BULK', from: 'DORALEH_FREEZONE', to: 'KALITY', + cargo: 'BEANS', tons: 200, note: 'D: PW2-only cargo, no alternative' }, + { ref: 'WGT-D2', dir: 'IMPORT', type: 'BULK', from: 'DORALEH_FREEZONE', to: 'KALITY', + cargo: 'SAND', tons: 300, note: 'D: CW3/CW4-only cargo' }, + + // --- Case E: overload one leg (way beyond any train) + { ref: 'WGT-E1', dir: 'IMPORT', type: 'BULK', from: 'DORALEH_FREEZONE', to: 'KALITY', + cargo: 'PERISHABLE', tons: 3000, note: 'E: 3000T - must overflow the loco pull limit' }, + { ref: 'WGT-E2', dir: 'IMPORT', type: 'BULK', from: 'DORALEH_FREEZONE', to: 'KALITY', + cargo: 'PERISHABLE', tons: 45, note: 'E: small bulk - must NOT share a wagon with A1' }, +]; + +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 totalVgm = isBulk + ? b.tons + : b.containers.reduce((s, l) => s + l.qty * l.vgm, 0); + + 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 = $7, + status = 'CLEARANCE_READY', payment_status = 'PENDING', + scheduling_status = 'ELIGIBLE', train_schedule_id = NULL, + cargo_free_text = $8, updated_at = now() + WHERE id = $1`, + [id, totalVgm, isBulk ? CARGO[b.cargo] : null, + YARD[b.from], YARD[b.to], b.type, b.dir, 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',$2,'WITHOUT_RETURN',$3, + false,'ETB',1,0,$4,$5,$6,$7,$8,'ELIGIBLE',false, + false,false,'ONE_TIME',false,$9, now(), now()) + RETURNING id`, + [b.ref, b.dir, totalVgm, YARD[b.from], YARD[b.to], + isBulk ? CARGO[b.cargo] : null, b.note, b.type, COMPANY], + ); + id = res.rows[0].id; + } + + // Container lines + await client.query(`DELETE FROM freight.booking_container WHERE booking_id = $1`, [id]); + if (!isBulk) { + for (const line of b.containers) { + 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,$7, now(), now())`, + [id, CONTAINER[line.type], line.qty, line.vgm, line.qty * line.vgm, + line.type === '40FT' ? line.qty : Math.ceil(line.qty / 2), + line.type === '40FT' ? '40ft' : '20ft'], + ); + } + } + out.push({ ref: b.ref, id, note: b.note }); + } + + console.table(out); + await client.end(); +} + +main().catch((err) => { + console.error(err.message); + process.exit(1); +}); 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 b36ed4639..7e527da6e 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 @@ -130,6 +130,7 @@ import { maxEdgeConsistUsage, perEdgeConsistUsage, validateContainerPlacements, + validateWagonCargoExclusivity, validateMixedTrainLimitsPerEdge, MAX_TEU_SLOTS_PER_WAGON, type ContainerPlacementInput, @@ -2200,12 +2201,23 @@ export class TrainSchedulingService { ].map((bookingId) => ({ trainScheduleId: scheduleId, bookingId })); await this.trainScheduleBookingsRepository.createMany(scheduleBookingRecords, manager); + // Leg spans for the per-edge exclusivity guard — a wagon may carry + // containers to Dire Dawa and bulk onward, never both at once. + const persistLegs = new Map( + bookings.flatMap((b) => { + const from = scheduleStops.indexOf(b.originYardId); + const to = scheduleStops.indexOf(b.destinationYardId); + return from >= 0 && to > from ? [[b.id, { from, to }] as const] : []; + }), + ); await this.persistAllocationsAndLoads( manager, savedWagons, wagonPlan, bookings, containerPlacements ?? [], + persistLegs, + Math.max(1, scheduleStops.length - 1), ); // The link above puts these bookings on the train: they are SCHEDULED, not @@ -5023,6 +5035,9 @@ export class TrainSchedulingService { trainLimits, stops, stopLabels, + // Cargo exclusivity is a per-edge rule: a wagon may carry containers + // to Dire Dawa and bulk onward from there, never both at once. + legByBookingId, ), ); if (requireContainerPlacements && resolvedMode !== 'BULK') { @@ -6048,6 +6063,9 @@ export class TrainSchedulingService { wagonPlan: WagonPlanSlot[], bookings: Booking[], containerPlacements: ContainerPlacementInput[] = [], + /** Booking id → stop-index span, for the per-edge exclusivity guard. */ + legs?: Map, + edgeCount = 1, ) { const bookingById = new Map(bookings.map((b) => [b.id, b])); const lineById = new Map( @@ -6081,16 +6099,18 @@ export class TrainSchedulingService { const trainSetWagon = savedWagons[i]; if (!slot || !trainSetWagon) continue; - // Last line of defense behind validateWagonCargoExclusivity: a wagon - // with bulk on it carries that one load only — never a container and - // never a second bulk booking. - if ( - slot.allocations.length > 1 && - slot.allocations.some((a) => a.loadType === AllocationLoadType.Bulk) - ) { - throw new BadRequestException( - `Wagon #${slot.sequenceNo} mixes bulk with other cargo — a wagon carrying bulk takes that one load only`, - ); + // Last line of defense behind validateWagonCargoExclusivity: while a + // bulk load rides, its wagon carries nothing else — no container and no + // second bulk booking. Loads on DISJOINT legs (a container that alights + // where the bulk boards) legitimately share the wagon, so the check is + // per corridor edge, using the same leg spans the plan was built with. + const exclusivityIssues = validateWagonCargoExclusivity( + [slot], + legs, + edgeCount, + ); + if (exclusivityIssues.length) { + throw new BadRequestException(exclusivityIssues[0]); } for (const alloc of slot.allocations) { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts index 5447a0b6e..cf2c384d9 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts @@ -502,20 +502,48 @@ export function sumWagonsRequired(booking: Booking, wagonPlan?: WagonPlanSlot[]) } /** - * One wagon carries one kind of cargo: a slot with a BULK allocation holds - * nothing else — no container beside it and no second bulk booking. Container - * allocations may still share a wagon with each other (TEU rules apply). + * One wagon carries one kind of cargo AT A TIME: while a bulk load rides, the + * wagon holds nothing else — no container beside it and no second bulk + * booking. Container allocations may share a wagon with each other (TEU rules + * apply). + * + * "At a time" is the whole rule: a wagon whose cargo alights at Dire Dawa is + * empty steel for whatever boards there, so an import container on + * Doraleh→Dire and bulk on Dire→Kality legitimately share one wagon. Pass + * `legs` (booking id → stop-index span) to check per corridor edge; without + * it every allocation is treated as riding the whole route, which is the + * correct reading for a single-leg train. */ -export function validateWagonCargoExclusivity(wagonPlan: WagonPlanSlot[]): string[] { +export function validateWagonCargoExclusivity( + wagonPlan: WagonPlanSlot[], + legs?: Map, + edgeCount = 1, +): string[] { const violations: string[] = []; + const edges = Math.max(1, edgeCount); + const spanOf = (bookingId: string) => { + const leg = legs?.get(bookingId); + if (!leg || leg.from < 0 || leg.to > edges || leg.from >= leg.to) { + return { from: 0, to: edges }; + } + return leg; + }; + for (const slot of wagonPlan) { - const hasBulk = slot.allocations.some( - (a) => a.loadType === AllocationLoadType.Bulk, - ); - if (hasBulk && slot.allocations.length > 1) { - violations.push( - `Wagon #${slot.sequenceNo} mixes bulk with other cargo — a wagon carrying bulk takes that one load only`, - ); + if (slot.allocations.length < 2) continue; + // Per edge: who is on this wagon while it rides that edge? + for (let edge = 0; edge < edges; edge += 1) { + const riding = slot.allocations.filter((a) => { + const span = spanOf(a.bookingId); + return span.from <= edge && edge < span.to; + }); + if (riding.length < 2) continue; + if (riding.some((a) => a.loadType === AllocationLoadType.Bulk)) { + violations.push( + `Wagon #${slot.sequenceNo} mixes bulk with other cargo — a wagon carrying bulk takes that one load only`, + ); + break; + } } } return violations; @@ -547,6 +575,9 @@ export function validateTrainLimits( wagonPlan: WagonPlanSlot[], wagonType: Pick, limits?: TrainLimitConfig, + /** Leg-aware cargo exclusivity — see {@link validateWagonCargoExclusivity}. */ + legs?: Map, + edgeCount?: number, ): string[] { const maxWeightTons = limits?.maxWeightTons ?? MAX_TRAIN_WEIGHT_TONS; const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS; @@ -564,7 +595,7 @@ export function validateTrainLimits( ); violations.push(...validateBulkWagonSlotWeights(wagonPlan)); - violations.push(...validateWagonCargoExclusivity(wagonPlan)); + violations.push(...validateWagonCargoExclusivity(wagonPlan, legs, edgeCount)); return violations; } @@ -578,6 +609,8 @@ export function validateMixedTrainLimits( wagonPlan: WagonPlanSlot[], wagonTypes: Array>, limits?: TrainLimitConfig, + legs?: Map, + edgeCount?: number, ): string[] { const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS; const minWagonLength = Math.min( @@ -591,6 +624,8 @@ export function validateMixedTrainLimits( wagonPlan, { lengthMeters: minWagonLength }, { ...limits, maxWagonsPerTrain }, + legs, + edgeCount, ); } @@ -608,8 +643,13 @@ export function validateMixedTrainLimitsPerEdge( stops: string[], /** Display names parallel to `stops` — violations then name the leg they hit. */ stopLabels?: string[], + /** Booking id → stop-index span, so cargo exclusivity is judged per edge. */ + legs?: Map, ): string[] { - if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits); + const edges = Math.max(1, stops.length - 1); + if (stops.length <= 2) { + return validateMixedTrainLimits(wagonPlan, wagonTypes, limits, legs, edges); + } const spans = slotSpans(wagonPlan, stops); const label = (i: number) => stopLabels?.[i] ?? stops[i]; const violations = new Set(); @@ -618,7 +658,13 @@ export function validateMixedTrainLimitsPerEdge( (_, i) => spans[i].from <= edge && edge < spans[i].to, ); if (!active.length) continue; - for (const violation of validateMixedTrainLimits(active, wagonTypes, limits)) { + for (const violation of validateMixedTrainLimits( + active, + wagonTypes, + limits, + legs, + edges, + )) { violations.add(`Leg ${label(edge)} → ${label(edge + 1)}: ${violation}`); } } 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 d30094b30..275e0f9fb 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 @@ -416,8 +416,14 @@ export function planWagonsWithStock(params: { } const allowedIds = new Set(candidates.map((wt) => wt.id)); const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20); + // A BULK wagon whose cargo alights before this unit boards is empty + // steel again and may carry containers on the later leg (and vice + // versa — see the bulk reuse pass). While both ride together, the + // kinds never mix. + const disjointFrom = (open: OpenSlot): boolean => + open.covered.to <= leg.from || leg.to <= open.covered.from; const fitsSlot = (open: OpenSlot): boolean => - open.kind === 'CONTAINER' && + (open.kind === 'CONTAINER' || disjointFrom(open)) && allowedIds.has(open.slot.wagonTypeId) && teuFits(open, leg, teu) && canExtendSpan(open, leg); @@ -457,6 +463,7 @@ export function planWagonsWithStock(params: { message: `Cargo type "${booking.cargoType?.cargoTypeName ?? booking.cargoType?.code ?? 'unknown'}" has no wagon types configured — set them in its configuration before scheduling.`, }; } + const allowedIds = new Set(candidates.map((wt) => wt.id)); // Break-bulk (PER_ITEM): `cargoTotalWeightVgm` is the ITEM COUNT and the // real tonnage lives in `bulkTotalWeightTons` — bookingCargoTons resolves // it either way. Items are indivisible, so a wagon takes whole items only, @@ -500,9 +507,61 @@ export function planWagonsWithStock(params: { ) : candidates; - // One bulk booking per wagon: a wagon carrying bulk takes that one - // booking's cargo only — never topped up from another booking, even of - // the same cargo type. Every bulk booking therefore opens its own wagons. + // One bulk booking per wagon PER LEG: a wagon carrying bulk takes that one + // booking's cargo for as long as it rides — never topped up from another + // booking on the same edges, even of the same cargo type. + // + // A wagon whose cargo ALIGHTS before this booking boards is free steel + // again, though: an import container uncoupled at Dire Dawa leaves its + // wagon empty for bulk loading there. Reuse those disjoint-leg slots + // before opening new stock — containers already do this, and without it a + // train with 3 wagons could not seat 3 wagons of leg-1 cargo plus 3 of + // leg-2 cargo. + const disjoint = (open: OpenSlot): boolean => + open.covered.to <= leg.from || leg.to <= open.covered.from; + const reusable = openSlots.filter( + (open) => + disjoint(open) && + allowedIds.has(open.slot.wagonTypeId) && + // A pooled wagon boards at its own yard; it cannot ride backwards. + !(open.pool && leg.from < open.covered.from), + ); + for (const open of reusable) { + if (perItem ? remainingItems <= 0 : remainingWeight <= 0) break; + const wagonType = candidates.find((wt) => wt.id === open.slot.wagonTypeId); + if (!wagonType) continue; + const room = bulkTonsPerWagon( + booking.cargoType, + open.slot.wagonTypeId, + Number(open.slot.capacityTons), + ); + if (!(room > 0)) continue; + let take: number; + if (perItem) { + const budget = Math.min( + bulkItemsFitFor(booking.cargoType, open.slot.wagonTypeId) ?? + Number.MAX_SAFE_INTEGER, + perItemTons > 0 ? Math.max(1, Math.floor(room / perItemTons)) : 1, + ); + const takeItems = Math.max(1, Math.min(budget, remainingItems)); + take = roundTons(Math.min(takeItems * perItemTons, remainingWeight)); + remainingItems -= takeItems; + } else { + take = roundTons(Math.min(room, remainingWeight)); + } + addAllocation( + open.slot, + booking.id, + booking.reference, + take, + AllocationLoadType.Bulk, + ); + // The wagon now rides this leg too — it is the same physical steel, so + // no extra stock is consumed beyond extending its span. + extendSpan(open, leg); + remainingWeight = roundTons(remainingWeight - take); + placedAnywhere = true; + } while ((perItem ? remainingItems > 0 : remainingWeight > 0) || !placedAnywhere) { // Per-item: openSlot's stock-depth tie-break would override the fit