mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix issue
This commit is contained in:
339
apps/edr-freight-api/scripts/run-legboard-tests.ts
Normal file
339
apps/edr-freight-api/scripts/run-legboard-tests.ts
Normal 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); });
|
||||
236
apps/edr-freight-api/scripts/run-s45-scenario.ts
Normal file
236
apps/edr-freight-api/scripts/run-s45-scenario.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* 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<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 rows = async (sql: string) => {
|
||||
const r: Array<{ typeId: string; wagonTypeId: string }> = await ds.query(sql);
|
||||
const map = new Map<string, WagonType[]>();
|
||||
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<Map<string, 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.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<string, string>): Promise<WagonStock> {
|
||||
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<string, number>();
|
||||
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<string, { from: number; to: number }>) => {
|
||||
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<Record<string, unknown>> = [];
|
||||
// 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); });
|
||||
96
apps/edr-freight-api/scripts/seed-s45-scenario.cjs
Normal file
96
apps/edr-freight-api/scripts/seed-s45-scenario.cjs
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Seeds the S-2026-00045 reported scenario into edr_dev (idempotent).
|
||||
* References SF45-* ("Scenario Fortyfive"). Rows are kept as evidence.
|
||||
*
|
||||
* SELECT * FROM freight.bookings WHERE reference LIKE 'SF45-%';
|
||||
*/
|
||||
const { Client } = require('pg');
|
||||
|
||||
const YARD = {
|
||||
DCT: 'fc558b95-da28-4fc3-8348-311a290c34ae',
|
||||
DIRE: 'f7b1686f-d43e-42aa-bc6a-d3d5849296c9',
|
||||
GMP: '61ae1e66-c229-4dcd-9851-b2b9424f3a95', // KALITY = GMP (Gelan)
|
||||
};
|
||||
const PERISHABLE = 'a5991d3a-d690-4b7e-98fd-ea3333aa16e7'; // NW5 30T / PW2 20T
|
||||
const FT40 = '349072e7-8a90-4c03-b682-08976abfd7e8';
|
||||
const COMPANY = '300d5510-e3a5-4858-bc28-6e3beda8ca80';
|
||||
|
||||
const BOOKINGS = [
|
||||
{ ref: 'SF45-1', type: 'CONTAINER', from: 'DCT', to: 'DIRE', qty: 9,
|
||||
note: 'S45: DCT->Dire 9x40ft' },
|
||||
{ ref: 'SF45-2', type: 'CONTAINER', from: 'DCT', to: 'DIRE', qty: 19,
|
||||
note: 'S45: DCT->Dire 19x40ft' },
|
||||
{ ref: 'SF45-3', type: 'CONTAINER', from: 'DCT', to: 'DIRE', qty: 19,
|
||||
note: 'S45: DCT->Dire 19x40ft (second)' },
|
||||
{ ref: 'SF45-4', type: 'BULK', from: 'DCT', to: 'GMP', tons: 695,
|
||||
note: 'S45: DCT->GMP 695T Perishable - THE ONE NOT BEING SELECTED' },
|
||||
{ ref: 'SF45-5', type: 'CONTAINER', from: 'DIRE', to: 'GMP', qty: 42,
|
||||
note: 'S45: Dire->GMP 42x40ft' },
|
||||
];
|
||||
|
||||
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 vgmPerUnit = 26;
|
||||
const totalVgm = isBulk ? b.tons : b.qty * vgmPerUnit;
|
||||
|
||||
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='IMPORT',
|
||||
status='CLEARANCE_READY', payment_status='PENDING',
|
||||
scheduling_status='ELIGIBLE', train_schedule_id=NULL,
|
||||
cargo_free_text=$7, updated_at=now()
|
||||
WHERE id=$1`,
|
||||
[id, totalVgm, isBulk ? PERISHABLE : null, YARD[b.from], YARD[b.to],
|
||||
b.type, 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','IMPORT','WITHOUT_RETURN',
|
||||
$2,false,'ETB',1,0,$3,$4,$5,$6,$7,'ELIGIBLE',false,false,false,
|
||||
'ONE_TIME',false,$8, now(), now())
|
||||
RETURNING id`,
|
||||
[b.ref, totalVgm, YARD[b.from], YARD[b.to],
|
||||
isBulk ? PERISHABLE : null, b.note, b.type, COMPANY]);
|
||||
id = res.rows[0].id;
|
||||
}
|
||||
|
||||
await client.query(`DELETE FROM freight.booking_container WHERE booking_id=$1`, [id]);
|
||||
if (!isBulk) {
|
||||
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,'40ft', now(), now())`,
|
||||
[id, FT40, b.qty, vgmPerUnit, b.qty * vgmPerUnit, b.qty]);
|
||||
}
|
||||
out.push({ ref: b.ref, id, note: b.note });
|
||||
}
|
||||
|
||||
console.table(out);
|
||||
await client.end();
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e.message); process.exit(1); });
|
||||
Reference in New Issue
Block a user