mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
363 lines
16 KiB
TypeScript
363 lines
16 KiB
TypeScript
/**
|
|
* 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<string, WagonType>;
|
|
codes: Map<string, string>;
|
|
}> {
|
|
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<string, WagonType[]>();
|
|
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<Booking[]> {
|
|
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<string, string>): Promise<WagonStock> {
|
|
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<string, number>();
|
|
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);
|
|
});
|