mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
changes
This commit is contained in:
362
apps/edr-freight-api/scripts/run-wagon-gate-tests.ts
Normal file
362
apps/edr-freight-api/scripts/run-wagon-gate-tests.ts
Normal file
@@ -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<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);
|
||||
});
|
||||
Reference in New Issue
Block a user