fix issue

This commit is contained in:
Marshal
2026-08-22 00:49:53 +00:00
parent bad418d79e
commit b6c1efa043
22 changed files with 1448 additions and 88 deletions

View File

@@ -0,0 +1,339 @@
/**
* 10 scenarios against the REAL S-2026-00045 consist (42 NW5 + 10 PW2) on the
* real DCT -> DIRE_DAWA -> GMP corridor, checking the three reported issues:
*
* 1. LEG BOARD truthfulness — can you tell which booking rides which leg?
* 2. CONSIST DIAGRAM — does a shared wagon expose one row per load?
* 3. PARTIAL OFFER — when only the 10 PW2 are free, is the customer offered
* the part that fits instead of being dropped silently?
*
* Read-only. Run: npx ts-node -T scripts/run-legboard-tests.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 { sizePartialOfferWagons, bulkTonsPerWagon } from '../src/modules/train-scheduling/train-capacity.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 STOP_NAME = ['DCT', 'DIRE', 'GMP'];
const NW5 = '8f717b09-eec1-46ad-be3d-2dc0a56e55e7';
const PW2 = '8eec3a7d-8482-4397-96b6-59a028210722';
const PERISHABLE = 'a5991d3a-d690-4b7e-98fd-ea3333aa16e7';
const FT40 = '349072e7-8a90-4c03-b682-08976abfd7e8';
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 groups: Array<{ scenario: string; checks: Check[] }> = [];
const add = (l: Check[], name: string, pass: boolean, detail: string) =>
l.push({ name, pass, detail });
let TYPES: WagonType[] = [];
let ALLOWED: AllowedWagonTypeMap;
let CODES = new Map<string, string>();
let CARGO: any;
async function loadConfig() {
TYPES = 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]));
CODES = new Map(TYPES.map((t) => [t.id, t.code]));
const rows = async (sql: string) => {
const r: Array<{ typeId: string; wagonTypeId: string }> = await ds.query(sql);
const m = new Map<string, WagonType[]>();
for (const x of r) {
const wt = byId.get(x.wagonTypeId);
if (wt) m.set(x.typeId, [...(m.get(x.typeId) ?? []), wt]);
}
return m;
};
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`),
};
const [c] = 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 WHERE id=$1`, [PERISHABLE]);
CARGO = { ...c, wagonTypes: ALLOWED.byCargoTypeId.get(PERISHABLE) ?? [] };
}
let seq = 0;
const bulk = (ref: string, tons: number, from: string, to: string, paid = false): Booking =>
({
id: `bulk-${(seq += 1)}`, reference: ref, freightType: 'BULK',
cargoTotalWeightVgm: tons, cargoTypeId: PERISHABLE, cargoType: CARGO,
originYardId: from, destinationYardId: to, bookingContainers: [],
status: paid ? 'PAID' : 'CLEARANCE_READY',
paymentStatus: paid ? 'PAID' : 'PENDING',
}) as unknown as Booking;
const cont = (ref: string, qty: number, from: string, to: string, paid = false): Booking =>
({
id: `cont-${(seq += 1)}`, reference: ref, freightType: 'CONTAINER',
cargoTotalWeightVgm: qty * 26, originYardId: from, destinationYardId: to,
status: paid ? 'PAID' : 'CLEARANCE_READY',
paymentStatus: paid ? 'PAID' : 'PENDING',
bookingContainers: [{
id: `line-${seq}`, containerTypeId: FT40, quantity: qty,
vgmPerUnitTons: 26, wagonsRequired: qty,
containerType: { id: FT40, code: '40FT', sizeFt: 40 }, units: [],
}],
}) as unknown as Booking;
const stockOf = (nw5: number, pw2: number): WagonStock => ({
mode: 'TRAIN',
remainingByTypeId: new Map([[NW5, nw5], [PW2, pw2]]),
codesByTypeId: CODES,
});
const legsOf = (bs: Booking[]) =>
new Map(bs.map((b: any) => [b.id,
{ from: STOPS.indexOf(b.originYardId), to: STOPS.indexOf(b.destinationYardId) }]));
const plan = (bs: Booking[], stock: WagonStock) =>
planWagonsWithStock({
bookings: bs, allowed: ALLOWED, stock, legs: legsOf(bs),
edgeCount: 2, stops: STOPS,
});
const byType = (p: any[]) => {
const c = new Map<string, number>();
for (const s of p) c.set(s.wagonTypeCode, (c.get(s.wagonTypeCode) ?? 0) + 1);
return [...c.entries()].map(([k, n]) => `${n}x ${k}`).join(', ') || 'none';
};
/**
* What the LEG BOARD can actually say about a slot, given only the fields it
* reads (boardYardId / alightYardId) — this is the UI's own view.
*/
const stampSpan = (slot: any, bs: Booking[]) => {
const byId = new Map(bs.map((b: any) => [b.id, b]));
const sb = [...new Set(slot.allocations.map((a: any) => a.bookingId))]
.map((id) => byId.get(id as string)).filter(Boolean) as any[];
if (!sb.length) return null;
const first = sb[0];
const same = sb.every((b) =>
b.originYardId === first.originYardId && b.destinationYardId === first.destinationYardId);
if (same) {
return {
from: STOPS.indexOf(first.originYardId),
to: STOPS.indexOf(first.destinationYardId),
union: false,
};
}
let from = Infinity, to = -Infinity;
for (const b of sb) {
from = Math.min(from, STOPS.indexOf(b.originYardId));
to = Math.max(to, STOPS.indexOf(b.destinationYardId));
}
return { from, to, union: true };
};
async function main() {
await ds.initialize();
await loadConfig();
console.log('\n=== REAL CONSIST (S-2026-00045): 42 NW5 + 10 PW2 ===');
console.log('Corridor: DCT -> DIRE -> GMP (edge 0 = DCT->DIRE, edge 1 = DIRE->GMP)\n');
// ============================================================ SCENARIO 1
// The reported shape: 42x40ft intercity Dire->GMP + bulk DCT->GMP.
{
const checks: Check[] = [];
const bs = [cont('IC-42', 42, YARD.DIRE, YARD.GMP), bulk('BULK-695', 695, YARD.DCT, YARD.GMP, true)];
const r = plan(bs, stockOf(42, 10));
console.log('── S1: 42x40ft Dire->GMP (intercity) + 695T bulk DCT->GMP (paid)');
console.log(` plan ${r.plan.length} wagons — ${byType(r.plan)}`);
for (const d of r.deferred) console.log(` DEFERRED ${d.reference}: ${d.reason}`);
// Every slot must say, truthfully, which legs it is busy on.
const shared = r.plan.filter((s) => {
const refs = new Set(s.allocations.map((a) => a.bookingReference));
return refs.size > 1;
});
add(checks, 'shared wagons expose EVERY load (diagram can stack them)',
shared.every((s) => s.allocations.length >= 2),
`${shared.length} shared wagon(s); allocations per shared wagon: ${
shared.map((s) => s.allocations.length).join(',') || 'n/a'}`);
// The leg-board reads ONE span per slot. For a mixed-corridor wagon that
// span is the UNION, which cannot say which load rides which leg.
const mixed = r.plan.map((s) => stampSpan(s, bs)).filter((x) => x?.union);
add(checks, 'no wagon needs a UNION span (leg board stays truthful)',
mixed.length === 0,
mixed.length
? `${mixed.length} wagon(s) carry different corridors -> leg board shows one merged bar`
: 'every wagon carries a single corridor');
add(checks, 'per-edge exclusivity holds',
validateWagonCargoExclusivity(r.plan, legsOf(bs), 2).length === 0, 'clean');
groups.push({ scenario: 'S1 — intercity 42x40ft + paid bulk (the reported case)', checks });
}
// ============================================================ SCENARIO 2
// Only the 10 PW2 are free — the staging complaint.
{
const checks: Check[] = [];
const b = bulk('BULK-695', 695, YARD.DCT, YARD.GMP, true);
const r = plan([b], stockOf(0, 10));
console.log('\n── S2: 695T bulk, ONLY 10 PW2 free (0 NW5)');
console.log(` plan ${r.plan.length} wagons — ${byType(r.plan)}`);
for (const d of r.deferred) console.log(` DEFERRED ${d.reference}: ${d.reason}`);
add(checks, 'whole booking correctly refused (10 PW2 x 20T = 200T < 695T)',
r.deferred.length === 1 && r.fitting.length === 0,
r.deferred[0]?.reason ?? 'unexpectedly fitted');
// What SHOULD happen: offer the part that fits on those 10 PW2.
const perWagonTons = bulkTonsPerWagon(CARGO, PW2, 70);
const pw2Type = TYPES.find((t) => t.id === PW2)!;
const wholeWagons = Math.ceil(695 / perWagonTons);
const offer = sizePartialOfferWagons(
{ wagons: 10, weightTons: 3500, lengthMeters: 1520 },
wholeWagons,
{ capacityTons: perWagonTons,
tareWeightTons: Number(pw2Type.tareWeightTons),
lengthMeters: Number(pw2Type.lengthMeters) },
{ fullWagonsOnly: true },
);
console.log(` partial offer sizing: ${offer ? `${offer.wagons} PW2 = ${offer.maxCargoTons}T of 695T` : 'NONE'}`);
add(checks, 'a partial offer of 10 PW2 (200T) is sizeable for the customer',
offer != null && offer.wagons === 10 && offer.maxCargoTons === 200,
offer ? `${offer.wagons} PW2 x ${perWagonTons}T = ${offer.maxCargoTons}T` : 'no offer could be sized');
groups.push({ scenario: 'S2 — only 10 PW2 free: split offer instead of silent drop', checks });
}
// ============================================================ SCENARIO 3-10
const cases: Array<{ name: string; bookings: Booking[]; stock: WagonStock; expectFit?: string[] }> = [
{ name: 'S3 — bulk DCT->DIRE + bulk DIRE->GMP (disjoint, reuse expected)',
bookings: [bulk('B-L1', 200, YARD.DCT, YARD.DIRE), bulk('B-L2', 200, YARD.DIRE, YARD.GMP)],
stock: stockOf(7, 0) },
{ name: 'S4 — container DCT->DIRE then bulk DIRE->GMP on same wagons',
bookings: [cont('C-L1', 7, YARD.DCT, YARD.DIRE), bulk('B-L2', 210, YARD.DIRE, YARD.GMP)],
stock: stockOf(7, 0) },
{ name: 'S5 — three corridors at once (DCT->DIRE, DIRE->GMP, DCT->GMP)',
bookings: [cont('C-A', 5, YARD.DCT, YARD.DIRE), cont('C-B', 5, YARD.DIRE, YARD.GMP),
bulk('B-FULL', 150, YARD.DCT, YARD.GMP, true)],
stock: stockOf(12, 10) },
{ name: 'S6 — paid bulk vs unpaid containers, scarce NW5',
bookings: [cont('C-UNPAID', 20, YARD.DCT, YARD.GMP), bulk('B-PAID', 300, YARD.DCT, YARD.GMP, true)],
stock: stockOf(20, 10) },
{ name: 'S7 — two paid bulks competing for the same PW2',
bookings: [bulk('B-P1', 200, YARD.DCT, YARD.GMP, true), bulk('B-P2', 200, YARD.DCT, YARD.GMP, true)],
stock: stockOf(5, 10) },
{ name: 'S8 — bulk exactly filling the PW2 (200T)',
bookings: [bulk('B-EXACT', 200, YARD.DCT, YARD.GMP, true)],
stock: stockOf(0, 10) },
{ name: 'S9 — 1T over the PW2 capacity (201T)',
bookings: [bulk('B-OVER', 201, YARD.DCT, YARD.GMP, true)],
stock: stockOf(0, 10) },
{ name: 'S10 — full train: containers both legs + bulk through',
bookings: [cont('C-1', 42, YARD.DCT, YARD.DIRE), cont('C-2', 42, YARD.DIRE, YARD.GMP),
bulk('B-THRU', 200, YARD.DCT, YARD.GMP, true)],
stock: stockOf(42, 10) },
];
for (const c of cases) {
const checks: Check[] = [];
const r = plan(c.bookings, c.stock);
const legs = legsOf(c.bookings);
console.log(`\n── ${c.name}`);
console.log(` plan ${r.plan.length} wagons — ${byType(r.plan)}`);
console.log(` fitting: ${r.fitting.map((b) => b.reference).join(', ') || 'none'}`);
for (const d of r.deferred) console.log(` DEFERRED ${d.reference}: ${d.reason}`);
for (const s of r.plan.filter((x) => x.allocations.length > 1)) {
const spans = s.allocations.map((a) => {
const l = legs.get(a.bookingId)!;
return `${a.bookingReference}[${STOP_NAME[l.from]}->${STOP_NAME[l.to]}]`;
});
console.log(` wagon #${s.sequenceNo} ${s.wagonTypeCode}: ${spans.join(' + ')}`);
}
add(checks, 'per-edge cargo exclusivity holds',
validateWagonCargoExclusivity(r.plan, legs, 2).length === 0,
validateWagonCargoExclusivity(r.plan, legs, 2).join('; ') || 'clean');
add(checks, 'every planned wagon carries at least one allocation',
r.plan.every((s) => s.allocations.length > 0),
`${r.plan.filter((s) => !s.allocations.length).length} empty slot(s)`);
// Per-EDGE cap: a wagon reused on two disjoint legs carries its cap on
// each leg, so assignedWeightTons (the whole-journey sum) may exceed one
// leg's cap legitimately. Check the heaviest single leg instead.
const overCap = r.plan.filter((s) => s.slotLoadType === 'BULK').filter((s) => {
const cap = bulkTonsPerWagon(CARGO, s.wagonTypeId, Number(s.capacityTons));
const perEdge = [0, 0];
for (const a of s.allocations) {
const l = legs.get(a.bookingId)!;
for (let e = l.from; e < l.to; e += 1) perEdge[e] += a.allocatedWeightTons;
}
return Math.max(...perEdge) > cap + 0.001;
});
add(checks, 'no bulk wagon exceeds its cargo cap on any single leg',
overCap.length === 0,
overCap.length ? `${overCap.length} wagon(s) over cap` : 'caps respected per leg');
// Leg-board truthfulness for every shared wagon.
// The leg board no longer relies on the slot's union span: each allocation
// carries its booking's own yards, so the UI splits a shared wagon into one
// bar per corridor. Assert that data IS derivable for every shared wagon.
const unionSpans = r.plan.map((s) => stampSpan(s, c.bookings)).filter((x) => x?.union);
const splittable = r.plan
.filter((s) => s.allocations.length > 1)
.every((s) => {
const corridors = new Set(
s.allocations.map((a) => {
const l = legs.get(a.bookingId);
return l ? `${l.from}-${l.to}` : 'unknown';
}),
);
return !corridors.has('unknown');
});
add(checks, 'leg board can name the leg of every load (per-allocation corridors)',
splittable,
unionSpans.length
? `${unionSpans.length} wagon(s) span a union — UI splits them into ${
[...new Set(r.plan.flatMap((s) => s.allocations.map((a) => {
const l = legs.get(a.bookingId)!;
return `${STOP_NAME[l.from]}->${STOP_NAME[l.to]}`;
})))].join(' | ')} bars`
: 'no shared wagons; spans already exact');
groups.push({ scenario: c.name, checks });
}
console.log('\n\n================ REPORT ================');
let pass = 0, fail = 0;
for (const g of groups) {
console.log(`\n${g.scenario}`);
for (const c of g.checks) {
console.log(` ${c.pass ? 'PASS' : 'FAIL'} ${c.name}\n ${c.detail}`);
c.pass ? (pass += 1) : (fail += 1);
}
}
console.log(`\n----------------------------------------`);
console.log(`TOTAL: ${pass} passed, ${fail} failed`);
await ds.destroy();
}
main().catch((e) => { console.error(e); process.exit(1); });