Merge pull request #1386 from Tria-plc/dev

dev
This commit is contained in:
marshal
2026-08-22 03:11:57 +03:00
committed by GitHub
5 changed files with 657 additions and 28 deletions

View 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);
});

View File

@@ -0,0 +1,142 @@
/**
* Seeds the wagon-allocation test bookings into edr_dev (idempotent) and
* prints their ids. Rows are kept — they are the evidence for the report.
*
* References are prefixed WGT- (Wagon Gate Test) so they are easy to find:
* SELECT * FROM freight.bookings WHERE reference LIKE 'WGT-%';
*/
const { Client } = require('pg');
const YARD = {
DORALEH_FREEZONE: 'fc558b95-da28-4fc3-8348-311a290c34ae',
DIRE_DAWA: 'f7b1686f-d43e-42aa-bc6a-d3d5849296c9',
KALITY: '61ae1e66-c229-4dcd-9851-b2b9424f3a95',
DMP: '7b658678-ce2b-41aa-8dc5-b38ba4a76e9b',
};
const CARGO = {
PERISHABLE: 'a5991d3a-d690-4b7e-98fd-ea3333aa16e7', // NW5 30T / PW2 20T
STEEL_BILLET: '8291ccc3-0dd9-4d78-aa44-284d028a19ce', // NW5 40T / PW2 10T (inverted)
BEANS: '9afd8eb3-975b-4ea7-a7db-04eb2c20a04d', // PW2 only, no cap
SAND: '8942884d-9991-42bb-87f7-a70930f8c43c', // CW3/CW4 only, no cap
};
const CONTAINER = {
'20FT': '77cf24ec-e74b-4bdb-b1cc-6f336379cc58',
'40FT': '349072e7-8a90-4c03-b682-08976abfd7e8',
};
const COMPANY = '300d5510-e3a5-4858-bc28-6e3beda8ca80';
/** Test bookings: 4 corridors, bulk + container, contested and uncontested. */
const BOOKINGS = [
// --- Case A: DORALEH_FREEZONE -> KALITY (full leg), bulk vs container contest
{ ref: 'WGT-A1', dir: 'IMPORT', type: 'BULK', from: 'DORALEH_FREEZONE', to: 'KALITY',
cargo: 'PERISHABLE', tons: 695, note: 'A: 695T Perishable, contends with containers for NW5' },
{ ref: 'WGT-A2', dir: 'IMPORT', type: 'CONTAINER', from: 'DORALEH_FREEZONE', to: 'KALITY',
containers: [{ type: '40FT', qty: 12, vgm: 26 }], note: 'A: 12x40ft, needs 12 container wagons' },
{ ref: 'WGT-A3', dir: 'IMPORT', type: 'CONTAINER', from: 'DORALEH_FREEZONE', to: 'KALITY',
containers: [{ type: '20FT', qty: 16, vgm: 12 }], note: 'A: 16x20ft = 8 wagons (TEU paired)' },
// --- Case B: sub-corridor legs on the same 3-stop route (leg overlap)
{ ref: 'WGT-B1', dir: 'IMPORT', type: 'CONTAINER', from: 'DORALEH_FREEZONE', to: 'DIRE_DAWA',
containers: [{ type: '20FT', qty: 6, vgm: 14 }], note: 'B: leg 1 only (DCT->Dire), 3 wagons' },
{ ref: 'WGT-B2', dir: 'IMPORT', type: 'BULK', from: 'DIRE_DAWA', to: 'KALITY',
cargo: 'PERISHABLE', tons: 120, note: 'B: leg 2 only (Dire->Kality) - may reuse leg-1 wagons' },
// --- Case C: cap inversion - Steel Billet is CHEAPER on NW5 (40T) than PW2 (10T)
{ ref: 'WGT-C1', dir: 'IMPORT', type: 'BULK', from: 'DORALEH_FREEZONE', to: 'KALITY',
cargo: 'STEEL_BILLET', tons: 400, note: 'C: inverted caps - must NOT blindly take PW2' },
// --- Case D: exclusive-type cargo (Beans=PW2 only, Sand=CW3/CW4 only)
{ ref: 'WGT-D1', dir: 'IMPORT', type: 'BULK', from: 'DORALEH_FREEZONE', to: 'KALITY',
cargo: 'BEANS', tons: 200, note: 'D: PW2-only cargo, no alternative' },
{ ref: 'WGT-D2', dir: 'IMPORT', type: 'BULK', from: 'DORALEH_FREEZONE', to: 'KALITY',
cargo: 'SAND', tons: 300, note: 'D: CW3/CW4-only cargo' },
// --- Case E: overload one leg (way beyond any train)
{ ref: 'WGT-E1', dir: 'IMPORT', type: 'BULK', from: 'DORALEH_FREEZONE', to: 'KALITY',
cargo: 'PERISHABLE', tons: 3000, note: 'E: 3000T - must overflow the loco pull limit' },
{ ref: 'WGT-E2', dir: 'IMPORT', type: 'BULK', from: 'DORALEH_FREEZONE', to: 'KALITY',
cargo: 'PERISHABLE', tons: 45, note: 'E: small bulk - must NOT share a wagon with A1' },
];
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 totalVgm = isBulk
? b.tons
: b.containers.reduce((s, l) => s + l.qty * l.vgm, 0);
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 = $7,
status = 'CLEARANCE_READY', payment_status = 'PENDING',
scheduling_status = 'ELIGIBLE', train_schedule_id = NULL,
cargo_free_text = $8, updated_at = now()
WHERE id = $1`,
[id, totalVgm, isBulk ? CARGO[b.cargo] : null,
YARD[b.from], YARD[b.to], b.type, b.dir, 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',$2,'WITHOUT_RETURN',$3,
false,'ETB',1,0,$4,$5,$6,$7,$8,'ELIGIBLE',false,
false,false,'ONE_TIME',false,$9, now(), now())
RETURNING id`,
[b.ref, b.dir, totalVgm, YARD[b.from], YARD[b.to],
isBulk ? CARGO[b.cargo] : null, b.note, b.type, COMPANY],
);
id = res.rows[0].id;
}
// Container lines
await client.query(`DELETE FROM freight.booking_container WHERE booking_id = $1`, [id]);
if (!isBulk) {
for (const line of b.containers) {
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,$7, now(), now())`,
[id, CONTAINER[line.type], line.qty, line.vgm, line.qty * line.vgm,
line.type === '40FT' ? line.qty : Math.ceil(line.qty / 2),
line.type === '40FT' ? '40ft' : '20ft'],
);
}
}
out.push({ ref: b.ref, id, note: b.note });
}
console.table(out);
await client.end();
}
main().catch((err) => {
console.error(err.message);
process.exit(1);
});

View File

@@ -130,6 +130,7 @@ import {
maxEdgeConsistUsage, maxEdgeConsistUsage,
perEdgeConsistUsage, perEdgeConsistUsage,
validateContainerPlacements, validateContainerPlacements,
validateWagonCargoExclusivity,
validateMixedTrainLimitsPerEdge, validateMixedTrainLimitsPerEdge,
MAX_TEU_SLOTS_PER_WAGON, MAX_TEU_SLOTS_PER_WAGON,
type ContainerPlacementInput, type ContainerPlacementInput,
@@ -2200,12 +2201,23 @@ export class TrainSchedulingService {
].map((bookingId) => ({ trainScheduleId: scheduleId, bookingId })); ].map((bookingId) => ({ trainScheduleId: scheduleId, bookingId }));
await this.trainScheduleBookingsRepository.createMany(scheduleBookingRecords, manager); await this.trainScheduleBookingsRepository.createMany(scheduleBookingRecords, manager);
// Leg spans for the per-edge exclusivity guard — a wagon may carry
// containers to Dire Dawa and bulk onward, never both at once.
const persistLegs = new Map(
bookings.flatMap((b) => {
const from = scheduleStops.indexOf(b.originYardId);
const to = scheduleStops.indexOf(b.destinationYardId);
return from >= 0 && to > from ? [[b.id, { from, to }] as const] : [];
}),
);
await this.persistAllocationsAndLoads( await this.persistAllocationsAndLoads(
manager, manager,
savedWagons, savedWagons,
wagonPlan, wagonPlan,
bookings, bookings,
containerPlacements ?? [], containerPlacements ?? [],
persistLegs,
Math.max(1, scheduleStops.length - 1),
); );
// The link above puts these bookings on the train: they are SCHEDULED, not // The link above puts these bookings on the train: they are SCHEDULED, not
@@ -5023,6 +5035,9 @@ export class TrainSchedulingService {
trainLimits, trainLimits,
stops, stops,
stopLabels, stopLabels,
// Cargo exclusivity is a per-edge rule: a wagon may carry containers
// to Dire Dawa and bulk onward from there, never both at once.
legByBookingId,
), ),
); );
if (requireContainerPlacements && resolvedMode !== 'BULK') { if (requireContainerPlacements && resolvedMode !== 'BULK') {
@@ -6048,6 +6063,9 @@ export class TrainSchedulingService {
wagonPlan: WagonPlanSlot[], wagonPlan: WagonPlanSlot[],
bookings: Booking[], bookings: Booking[],
containerPlacements: ContainerPlacementInput[] = [], containerPlacements: ContainerPlacementInput[] = [],
/** Booking id → stop-index span, for the per-edge exclusivity guard. */
legs?: Map<string, { from: number; to: number }>,
edgeCount = 1,
) { ) {
const bookingById = new Map(bookings.map((b) => [b.id, b])); const bookingById = new Map(bookings.map((b) => [b.id, b]));
const lineById = new Map( const lineById = new Map(
@@ -6081,16 +6099,18 @@ export class TrainSchedulingService {
const trainSetWagon = savedWagons[i]; const trainSetWagon = savedWagons[i];
if (!slot || !trainSetWagon) continue; if (!slot || !trainSetWagon) continue;
// Last line of defense behind validateWagonCargoExclusivity: a wagon // Last line of defense behind validateWagonCargoExclusivity: while a
// with bulk on it carries that one load only — never a container and // bulk load rides, its wagon carries nothing else — no container and no
// never a second bulk booking. // second bulk booking. Loads on DISJOINT legs (a container that alights
if ( // where the bulk boards) legitimately share the wagon, so the check is
slot.allocations.length > 1 && // per corridor edge, using the same leg spans the plan was built with.
slot.allocations.some((a) => a.loadType === AllocationLoadType.Bulk) const exclusivityIssues = validateWagonCargoExclusivity(
) { [slot],
throw new BadRequestException( legs,
`Wagon #${slot.sequenceNo} mixes bulk with other cargo — a wagon carrying bulk takes that one load only`, edgeCount,
); );
if (exclusivityIssues.length) {
throw new BadRequestException(exclusivityIssues[0]);
} }
for (const alloc of slot.allocations) { for (const alloc of slot.allocations) {

View File

@@ -502,20 +502,48 @@ export function sumWagonsRequired(booking: Booking, wagonPlan?: WagonPlanSlot[])
} }
/** /**
* One wagon carries one kind of cargo: a slot with a BULK allocation holds * One wagon carries one kind of cargo AT A TIME: while a bulk load rides, the
* nothing else — no container beside it and no second bulk booking. Container * wagon holds nothing else — no container beside it and no second bulk
* allocations may still share a wagon with each other (TEU rules apply). * booking. Container allocations may share a wagon with each other (TEU rules
* apply).
*
* "At a time" is the whole rule: a wagon whose cargo alights at Dire Dawa is
* empty steel for whatever boards there, so an import container on
* Doraleh→Dire and bulk on Dire→Kality legitimately share one wagon. Pass
* `legs` (booking id → stop-index span) to check per corridor edge; without
* it every allocation is treated as riding the whole route, which is the
* correct reading for a single-leg train.
*/ */
export function validateWagonCargoExclusivity(wagonPlan: WagonPlanSlot[]): string[] { export function validateWagonCargoExclusivity(
wagonPlan: WagonPlanSlot[],
legs?: Map<string, { from: number; to: number }>,
edgeCount = 1,
): string[] {
const violations: string[] = []; const violations: string[] = [];
const edges = Math.max(1, edgeCount);
const spanOf = (bookingId: string) => {
const leg = legs?.get(bookingId);
if (!leg || leg.from < 0 || leg.to > edges || leg.from >= leg.to) {
return { from: 0, to: edges };
}
return leg;
};
for (const slot of wagonPlan) { for (const slot of wagonPlan) {
const hasBulk = slot.allocations.some( if (slot.allocations.length < 2) continue;
(a) => a.loadType === AllocationLoadType.Bulk, // Per edge: who is on this wagon while it rides that edge?
); for (let edge = 0; edge < edges; edge += 1) {
if (hasBulk && slot.allocations.length > 1) { const riding = slot.allocations.filter((a) => {
const span = spanOf(a.bookingId);
return span.from <= edge && edge < span.to;
});
if (riding.length < 2) continue;
if (riding.some((a) => a.loadType === AllocationLoadType.Bulk)) {
violations.push( violations.push(
`Wagon #${slot.sequenceNo} mixes bulk with other cargo — a wagon carrying bulk takes that one load only`, `Wagon #${slot.sequenceNo} mixes bulk with other cargo — a wagon carrying bulk takes that one load only`,
); );
break;
}
} }
} }
return violations; return violations;
@@ -547,6 +575,9 @@ export function validateTrainLimits(
wagonPlan: WagonPlanSlot[], wagonPlan: WagonPlanSlot[],
wagonType: Pick<WagonType, 'lengthMeters'>, wagonType: Pick<WagonType, 'lengthMeters'>,
limits?: TrainLimitConfig, limits?: TrainLimitConfig,
/** Leg-aware cargo exclusivity — see {@link validateWagonCargoExclusivity}. */
legs?: Map<string, { from: number; to: number }>,
edgeCount?: number,
): string[] { ): string[] {
const maxWeightTons = limits?.maxWeightTons ?? MAX_TRAIN_WEIGHT_TONS; const maxWeightTons = limits?.maxWeightTons ?? MAX_TRAIN_WEIGHT_TONS;
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS; const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
@@ -564,7 +595,7 @@ export function validateTrainLimits(
); );
violations.push(...validateBulkWagonSlotWeights(wagonPlan)); violations.push(...validateBulkWagonSlotWeights(wagonPlan));
violations.push(...validateWagonCargoExclusivity(wagonPlan)); violations.push(...validateWagonCargoExclusivity(wagonPlan, legs, edgeCount));
return violations; return violations;
} }
@@ -578,6 +609,8 @@ export function validateMixedTrainLimits(
wagonPlan: WagonPlanSlot[], wagonPlan: WagonPlanSlot[],
wagonTypes: Array<Pick<WagonType, 'lengthMeters'>>, wagonTypes: Array<Pick<WagonType, 'lengthMeters'>>,
limits?: TrainLimitConfig, limits?: TrainLimitConfig,
legs?: Map<string, { from: number; to: number }>,
edgeCount?: number,
): string[] { ): string[] {
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS; const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
const minWagonLength = Math.min( const minWagonLength = Math.min(
@@ -591,6 +624,8 @@ export function validateMixedTrainLimits(
wagonPlan, wagonPlan,
{ lengthMeters: minWagonLength }, { lengthMeters: minWagonLength },
{ ...limits, maxWagonsPerTrain }, { ...limits, maxWagonsPerTrain },
legs,
edgeCount,
); );
} }
@@ -608,8 +643,13 @@ export function validateMixedTrainLimitsPerEdge(
stops: string[], stops: string[],
/** Display names parallel to `stops` — violations then name the leg they hit. */ /** Display names parallel to `stops` — violations then name the leg they hit. */
stopLabels?: string[], stopLabels?: string[],
/** Booking id → stop-index span, so cargo exclusivity is judged per edge. */
legs?: Map<string, { from: number; to: number }>,
): string[] { ): string[] {
if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits); const edges = Math.max(1, stops.length - 1);
if (stops.length <= 2) {
return validateMixedTrainLimits(wagonPlan, wagonTypes, limits, legs, edges);
}
const spans = slotSpans(wagonPlan, stops); const spans = slotSpans(wagonPlan, stops);
const label = (i: number) => stopLabels?.[i] ?? stops[i]; const label = (i: number) => stopLabels?.[i] ?? stops[i];
const violations = new Set<string>(); const violations = new Set<string>();
@@ -618,7 +658,13 @@ export function validateMixedTrainLimitsPerEdge(
(_, i) => spans[i].from <= edge && edge < spans[i].to, (_, i) => spans[i].from <= edge && edge < spans[i].to,
); );
if (!active.length) continue; if (!active.length) continue;
for (const violation of validateMixedTrainLimits(active, wagonTypes, limits)) { for (const violation of validateMixedTrainLimits(
active,
wagonTypes,
limits,
legs,
edges,
)) {
violations.add(`Leg ${label(edge)}${label(edge + 1)}: ${violation}`); violations.add(`Leg ${label(edge)}${label(edge + 1)}: ${violation}`);
} }
} }

View File

@@ -416,8 +416,14 @@ export function planWagonsWithStock(params: {
} }
const allowedIds = new Set(candidates.map((wt) => wt.id)); const allowedIds = new Set(candidates.map((wt) => wt.id));
const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20); const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20);
// A BULK wagon whose cargo alights before this unit boards is empty
// steel again and may carry containers on the later leg (and vice
// versa — see the bulk reuse pass). While both ride together, the
// kinds never mix.
const disjointFrom = (open: OpenSlot): boolean =>
open.covered.to <= leg.from || leg.to <= open.covered.from;
const fitsSlot = (open: OpenSlot): boolean => const fitsSlot = (open: OpenSlot): boolean =>
open.kind === 'CONTAINER' && (open.kind === 'CONTAINER' || disjointFrom(open)) &&
allowedIds.has(open.slot.wagonTypeId) && allowedIds.has(open.slot.wagonTypeId) &&
teuFits(open, leg, teu) && teuFits(open, leg, teu) &&
canExtendSpan(open, leg); canExtendSpan(open, leg);
@@ -457,6 +463,7 @@ export function planWagonsWithStock(params: {
message: `Cargo type "${booking.cargoType?.cargoTypeName ?? booking.cargoType?.code ?? 'unknown'}" has no wagon types configured — set them in its configuration before scheduling.`, message: `Cargo type "${booking.cargoType?.cargoTypeName ?? booking.cargoType?.code ?? 'unknown'}" has no wagon types configured — set them in its configuration before scheduling.`,
}; };
} }
const allowedIds = new Set(candidates.map((wt) => wt.id));
// Break-bulk (PER_ITEM): `cargoTotalWeightVgm` is the ITEM COUNT and the // Break-bulk (PER_ITEM): `cargoTotalWeightVgm` is the ITEM COUNT and the
// real tonnage lives in `bulkTotalWeightTons` — bookingCargoTons resolves // real tonnage lives in `bulkTotalWeightTons` — bookingCargoTons resolves
// it either way. Items are indivisible, so a wagon takes whole items only, // it either way. Items are indivisible, so a wagon takes whole items only,
@@ -500,9 +507,61 @@ export function planWagonsWithStock(params: {
) )
: candidates; : candidates;
// One bulk booking per wagon: a wagon carrying bulk takes that one // One bulk booking per wagon PER LEG: a wagon carrying bulk takes that one
// booking's cargo only — never topped up from another booking, even of // booking's cargo for as long as it rides — never topped up from another
// the same cargo type. Every bulk booking therefore opens its own wagons. // booking on the same edges, even of the same cargo type.
//
// A wagon whose cargo ALIGHTS before this booking boards is free steel
// again, though: an import container uncoupled at Dire Dawa leaves its
// wagon empty for bulk loading there. Reuse those disjoint-leg slots
// before opening new stock — containers already do this, and without it a
// train with 3 wagons could not seat 3 wagons of leg-1 cargo plus 3 of
// leg-2 cargo.
const disjoint = (open: OpenSlot): boolean =>
open.covered.to <= leg.from || leg.to <= open.covered.from;
const reusable = openSlots.filter(
(open) =>
disjoint(open) &&
allowedIds.has(open.slot.wagonTypeId) &&
// A pooled wagon boards at its own yard; it cannot ride backwards.
!(open.pool && leg.from < open.covered.from),
);
for (const open of reusable) {
if (perItem ? remainingItems <= 0 : remainingWeight <= 0) break;
const wagonType = candidates.find((wt) => wt.id === open.slot.wagonTypeId);
if (!wagonType) continue;
const room = bulkTonsPerWagon(
booking.cargoType,
open.slot.wagonTypeId,
Number(open.slot.capacityTons),
);
if (!(room > 0)) continue;
let take: number;
if (perItem) {
const budget = Math.min(
bulkItemsFitFor(booking.cargoType, open.slot.wagonTypeId) ??
Number.MAX_SAFE_INTEGER,
perItemTons > 0 ? Math.max(1, Math.floor(room / perItemTons)) : 1,
);
const takeItems = Math.max(1, Math.min(budget, remainingItems));
take = roundTons(Math.min(takeItems * perItemTons, remainingWeight));
remainingItems -= takeItems;
} else {
take = roundTons(Math.min(room, remainingWeight));
}
addAllocation(
open.slot,
booking.id,
booking.reference,
take,
AllocationLoadType.Bulk,
);
// The wagon now rides this leg too — it is the same physical steel, so
// no extra stock is consumed beyond extending its span.
extendSpan(open, leg);
remainingWeight = roundTons(remainingWeight - take);
placedAnywhere = true;
}
while ((perItem ? remainingItems > 0 : remainingWeight > 0) || !placedAnywhere) { while ((perItem ? remainingItems > 0 : remainingWeight > 0) || !placedAnywhere) {
// Per-item: openSlot's stock-depth tie-break would override the fit // Per-item: openSlot's stock-depth tie-break would override the fit