mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
237 lines
9.7 KiB
TypeScript
237 lines
9.7 KiB
TypeScript
/**
|
|
* 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); });
|