Merge pull request #1387 from Tria-plc/freight_feature/usermanagement

fix issue
This commit is contained in:
marshal
2026-08-22 03:50:34 +03:00
committed by GitHub
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); });

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

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

View File

@@ -1,5 +1,6 @@
import { Freight } from "@edr/types";
import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "../bookings/entities/booking-wagon-cancellation.entity";
import { BillingService } from "./billing.service";
/**
@@ -1033,3 +1034,73 @@ describe("BillingService.document", () => {
expect(render).not.toHaveBeenCalled();
});
});
/**
* The pay-window guard belongs to the freight invoice. A wagon-cancellation fee
* rides source=booking but is raised on an already-PAID booking, so it inherits
* a deadline that has long passed — guarding it would make the fee permanently
* unsettleable.
*/
describe("BillingService.confirmOfflinePayment pay-window guard", () => {
const PAST = new Date(Date.now() - 86_400_000);
function makeService(invoiceType: string) {
const invoice = {
id: "inv-1",
source: Freight.InvoiceSource.Booking,
sourceId: "booking-1",
type: invoiceType,
currency: "ETB",
status: Freight.InvoiceStatus.Issued,
balanceAmount: 500,
};
const recordPayment = jest.fn().mockResolvedValue(invoice);
const dataSource = {
getRepository: () => ({
findOne: async () => ({ id: "booking-1", paymentDeadline: PAST }),
}),
};
const service = new BillingService(
dataSource as never,
{ findById: async () => invoice } as never,
{} as never,
makeEvents() as never,
{} as never,
{} as never,
{} as never,
{ upload: async () => ({ id: "file-1", name: "slip.pdf" }) } as never,
{ get: () => undefined } as never,
{ isEnabled: async () => true } as never,
);
(service as unknown as { recordPayment: unknown }).recordPayment =
recordPayment;
return { service, recordPayment };
}
const slip = { originalname: "slip.pdf" } as never;
it("refuses a freight invoice once the pay window has closed", async () => {
const { service } = makeService("PREPAID");
await expect(
service.confirmOfflinePayment("inv-1", slip, {}),
).rejects.toThrow(/payment window has closed/i);
});
it("settles a wagon-cancellation fee despite the closed window", async () => {
const { service, recordPayment } = makeService(
WAGON_CANCEL_FEE_INVOICE_TYPE,
);
await service.confirmOfflinePayment("inv-1", slip, {});
expect(recordPayment).toHaveBeenCalledWith(
"inv-1",
expect.objectContaining({ amount: 500, method: "BANK_TRANSFER" }),
);
});
it("still requires the bank slip for a cancellation fee", async () => {
const { service } = makeService(WAGON_CANCEL_FEE_INVOICE_TYPE);
await expect(
service.confirmOfflinePayment("inv-1", undefined, {}),
).rejects.toThrow(/slip file is required/i);
});
});

View File

@@ -14,6 +14,7 @@ import { DataSource, EntityManager, In, SelectQueryBuilder } from "typeorm";
import { Booking } from "../bookings/entities/booking.entity";
import { AdditionalCharge } from "../bookings/entities/additional-charge.entity";
import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "../bookings/entities/booking-wagon-cancellation.entity";
// Entity-only import (no module edge): portal reads resolve shipping-line
// payers straight off the table.
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
@@ -710,7 +711,14 @@ export class BillingService {
throw new BadRequestException("The bank payment slip file is required.");
}
if (invoice.source === "booking") {
// The pay window belongs to the freight invoice. A wagon-cancellation fee
// rides source=booking but is raised on an ALREADY-PAID booking, so it
// inherits a deadline that has long passed — guarding it would make the fee
// permanently unsettleable.
if (
invoice.source === "booking" &&
invoice.type !== WAGON_CANCEL_FEE_INVOICE_TYPE
) {
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: invoice.sourceId },
select: ["id", "paymentDeadline"],

View File

@@ -36,6 +36,7 @@ import {
import { BookingsRepository } from './bookings.repository';
import {
RebookCancelledWagonsDto,
RebookContainerLineDto,
RequestWagonCancellationDto,
} from './dto/wagon-cancellation.dto';
import { Booking } from './entities/booking.entity';
@@ -45,8 +46,11 @@ import {
BookingWagonCancellation,
CancelledQuantities,
CancelledUnitSnapshot,
WAGON_CANCEL_FEE_INVOICE_TYPE,
} from './entities/booking-wagon-cancellation.entity';
export { WAGON_CANCEL_FEE_INVOICE_TYPE };
/**
* rates.rate_type of the cancellation fee — an existing rate-engine type
* (trigger CANCELLATION, never auto-applied to booking pricing). Staff
@@ -59,8 +63,6 @@ export const WAGON_CANCELLATION_FEE_RATE_TYPE = 'CANCELLATION_FEE';
/** `booking_container.container_size` is stored as "20ft"/"40ft" — `Number()` on it is NaN. */
const sizeFtOf = (size: string | number | null | undefined): number =>
parseInt(String(size ?? ''), 10);
/** invoices.type of the fee invoice — the settlement branch key in BookingInvoiceService. */
export const WAGON_CANCEL_FEE_INVOICE_TYPE = 'WAGON_CANCEL_FEE';
const round2 = (n: number): number => Math.round(n * 100) / 100;
const round3 = (n: number): number => Math.round(n * 1000) / 1000;
@@ -767,7 +769,7 @@ export class BookingWagonCancellationService {
);
}
const createDto = this.buildRebookDto(row, dto.scheduledDate);
const createDto = this.buildRebookDto(row, dto.scheduledDate, dto.containers);
// Same currency as the source booking — the credit is in it.
createDto.paymentCurrency = source.paymentCurrency ?? undefined;
const created = await this.contractBooking.createUnderContract(
@@ -1460,11 +1462,25 @@ export class BookingWagonCancellationService {
private buildRebookDto(
row: BookingWagonCancellation,
scheduledDate: string,
overrides?: RebookContainerLineDto[],
): CreateBookingUnderContractDto {
const dto: CreateBookingUnderContractDto = { scheduledDate };
const q = row.cancelledQuantities;
if (q.bySize && Object.keys(q.bySize).length) {
// Unit overrides may rename containers, change seals and VGM — but the
// cancelled sizes and quantities are the contract of the credit: a size
// not on the credit, or a wrong unit count, is rejected.
const overrideBySize = new Map(
(overrides ?? []).map((o) => [o.containerSize, o.units]),
);
for (const size of overrideBySize.keys()) {
if (!(size in q.bySize)) {
throw new BadRequestException(
`The credit has no ${size} containers — sizes and quantities must match the cancelled booking.`,
);
}
}
const units = q.units ?? [];
dto.containers = Object.entries(q.bySize).map(([size, quantity]) => {
const sized = units.filter((u) => u.containerSize === size);
@@ -1473,13 +1489,23 @@ export class BookingWagonCancellationService {
`Credit is missing unit snapshots for size ${size} (${sized.length}/${quantity}) — contact EDR support.`,
);
}
const replacement = overrideBySize.get(size);
if (replacement && replacement.length !== quantity) {
throw new BadRequestException(
`The credit covers exactly ${quantity} × ${size} — you entered ${replacement.length}. Quantities cannot change on a rebook.`,
);
}
return {
containerSize: size,
quantity,
units: sized.map((u) => ({
containerNumber: u.containerNumber,
sealNumber: u.sealNumber ?? undefined,
vgmTons: u.vgmTons,
// Hazardous/reefer flags always ride from the snapshot (the cargo is
// the same cargo); number/seal/VGM come from the override when given.
units: sized.map((u, i) => ({
containerNumber: replacement?.[i]?.containerNumber ?? u.containerNumber,
sealNumber: replacement
? (replacement[i]?.sealNumber ?? undefined)
: (u.sealNumber ?? undefined),
vgmTons: replacement?.[i]?.vgmTons ?? u.vgmTons,
isHazardous: u.isHazardous,
isReefer: u.isReefer,
})),

View File

@@ -52,6 +52,7 @@ import {
FreightType,
} from './entities/booking.entity';
import { Booking } from './entities/booking.entity';
import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity';
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
import { FileRecord } from '../files/entities/file.entity';
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
@@ -2262,16 +2263,25 @@ export class BookingsService {
return this.findById(booking.id);
}
/** Upload documents for a DRAFT booking. */
/**
* Upload documents for a DRAFT booking — or for a booking created by
* rebooking a wagon-cancellation credit, whose paperwork may have changed
* with the new containers (old documents stay; new ones ride alongside).
*/
async uploadDocuments(
id: string,
files: Express.Multer.File[],
): Promise<Booking> {
const booking = await this.findById(id);
if (booking.status !== 'DRAFT') {
throw new BadRequestException(
'Documents can only be uploaded for DRAFT bookings',
);
const rebooked = await this.dataSource
.getRepository(BookingWagonCancellation)
.findOne({ where: { rebookedBookingId: id } });
if (!rebooked) {
throw new BadRequestException(
'Documents can only be uploaded for DRAFT bookings',
);
}
}
await this.filesService.uploadMany(id, 'bookings', files);
return this.findById(id);

View File

@@ -67,10 +67,60 @@ export class RequestWagonCancellationDto {
reason?: string;
}
export class RebookUnitDto {
@ApiProperty({ description: 'Container number for the rebooked unit' })
@IsString()
@MaxLength(64)
containerNumber!: string;
@ApiPropertyOptional({ description: 'Seal number' })
@IsOptional()
@IsString()
@MaxLength(64)
sealNumber?: string;
@ApiPropertyOptional({ description: 'VGM (tons) of the unit' })
@IsOptional()
@IsNumber()
@Min(0)
vgmTons?: number;
}
export class RebookContainerLineDto {
@ApiProperty({ description: 'Container size as stored on the credit, e.g. "20ft"' })
@IsString()
containerSize!: string;
@ApiProperty({
description:
'The rebooked units for this size — count MUST equal the cancelled quantity',
type: [RebookUnitDto],
})
@IsArray()
@ArrayNotEmpty()
@ValidateNested({ each: true })
@Type(() => RebookUnitDto)
units!: RebookUnitDto[];
}
export class RebookCancelledWagonsDto {
@ApiProperty({ description: 'Shipment day the credit is rebooked onto (ISO date)' })
@IsDateString()
scheduledDate!: string;
@ApiPropertyOptional({
description:
'Optional unit overrides: container number / seal / VGM may change, but ' +
'sizes and quantities must match the cancelled booking exactly. Sizes ' +
'omitted here keep their original units.',
type: [RebookContainerLineDto],
})
@IsOptional()
@IsArray()
@ArrayNotEmpty()
@ValidateNested({ each: true })
@Type(() => RebookContainerLineDto)
containers?: RebookContainerLineDto[];
}
export class FilterWagonCancellationsDto {

View File

@@ -5,6 +5,9 @@ import { Invoice } from '../../billing/entities/invoice.entity';
import { Rate } from '../../rule-engine/entities/rate.entity';
import { Booking } from './booking.entity';
/** `invoices.type` of the wagon-cancellation fee invoice — the settlement branch key in BookingInvoiceService. */
export const WAGON_CANCEL_FEE_INVOICE_TYPE = 'WAGON_CANCEL_FEE';
export const WAGON_CANCELLATION_STATUSES = [
// Requested; fee invoice open; wagons still allocated to the customer.
'FEE_PENDING',

View File

@@ -8821,6 +8821,13 @@ export class TrainSchedulingService {
allocatedWeightTons: roundTons(Number(allocation.allocatedWeightTons)),
loadType: allocation.loadType ?? null,
status: allocation.status,
// THIS load's own corridor, not the wagon's union span.
// A wagon reused across disjoint legs carries two loads
// with different yards; without these the leg board can
// only draw one merged bar and cannot say which load
// rides which leg.
originYardId: allocation.booking?.originYardId ?? null,
destinationYardId: allocation.booking?.destinationYardId ?? null,
containerItems: (containerItemsByAllocation.get(allocation.id) ?? []).map(
(item) => ({
id: item.id,

View File

@@ -35,11 +35,34 @@ export type DeferredBookingRow = {
shortage?: BookingWagonShortage | null;
};
/** A booking the customer has already paid for. */
const isPaid = (booking: Booking): boolean =>
booking.paymentStatus === 'PAID' || booking.status === 'PAID';
/**
* Seating order for the wagon planner.
*
* Government first, then PAID bookings, then priority score, then date.
*
* Payment ranks above priority score on purpose: money has changed hands and
* the customer was promised space on THIS train. Without it the planner
* seated an unpaid booking that merely arrived earlier and left a paid one
* with no wagon — the reported S-2026-00045 case, where a paid 695T bulk
* booking lost every wagon to unpaid container bookings and vanished from
* the train with free PW2 still standing in the consist.
*
* This only decides who is seated FIRST when the train is oversubscribed. It
* never invents capacity: an oversubscribed train still defers someone, and
* that someone is now the party who has not paid.
*/
export function sortBookingsForScheduling(bookings: Booking[]): Booking[] {
return [...bookings].sort((a, b) => {
const govDiff = Number(Boolean(b.isGovernment)) - Number(Boolean(a.isGovernment));
if (govDiff !== 0) return govDiff;
const paidDiff = Number(isPaid(b)) - Number(isPaid(a));
if (paidDiff !== 0) return paidDiff;
const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0);
if (priorityDiff !== 0) return priorityDiff;

View File

@@ -154,10 +154,46 @@ const shortageFor = (
),
)
: Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
const wagonsAvailable = candidates.reduce(
(sum, wt) => sum + availableOf(wt.id),
0,
);
const freeByType = candidates.map((wt) => ({ wt, free: availableOf(wt.id) }));
const wagonsAvailable = freeByType.reduce((sum, c) => sum + c.free, 0);
// PER_TON bulk: a bare wagon COUNT lies when the types carry different
// tonnage for this cargo. 14 NW5 (30T) + 10 PW2 (20T) is "24 wagons free"
// against a 24-wagon need, yet only 620T of the 695T booking fits — which
// is how a deferral could read "needs 24, 24 available (short 1)". Size the
// shortfall in the wagons the cargo's OWN caps require: how many more
// wagons of the best remaining type would carry the leftover tonnage.
const tons = bookingCargoTons(booking);
const perItem =
Number(booking.bulkTotalWeightTons ?? 0) > 0 &&
Number(booking.cargoTotalWeightVgm ?? 0) > 0;
if (booking.freightType === 'BULK' && !perItem && tons > 0) {
let seatable = 0;
let usedWagons = 0;
for (const { wt, free } of freeByType) {
const perWagon = bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons));
if (!(perWagon > 0) || free <= 0) continue;
seatable += free * perWagon;
usedWagons += free;
}
if (seatable < tons) {
const bestPerWagon = Math.max(
1,
...candidates.map((wt) =>
bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons)),
),
);
return {
wagonTypeCodes: [...new Set(candidates.map((wt) => wt.code))].join('/'),
wagonsNeeded,
wagonsAvailable: usedWagons,
// Wagons of the best type still missing to carry the leftover tonnage.
wagonsShort: Math.max(1, Math.ceil((tons - seatable) / bestPerWagon)),
};
}
}
return {
wagonTypeCodes: [...new Set(candidates.map((wt) => wt.code))].join('/'),
wagonsNeeded,

View File

@@ -22,6 +22,15 @@ type Slot = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
type Stop = { yardId: string; label: string };
type Span = [number, number];
/** The allocations of one slot that ride the same corridor — one drawn bar. */
type SlotPart = {
slot: Slot;
span: Span;
loaded: boolean;
/** Allocations riding THIS span (all of the slot's when it is not split). */
allocations: NonNullable<Slot["allocations"]>;
};
/** One physical wagon of the consist with every slot (leg load) pinned to it. */
interface WagonRow {
key: string;
@@ -30,12 +39,57 @@ interface WagonRow {
position: number;
typeCode: string | null;
capacityTons: number;
slots: Array<{ slot: Slot; span: Span; loaded: boolean }>;
slots: SlotPart[];
}
const round1 = (n: number) => Math.round(n * 10) / 10;
const overlaps = (a: Span, b: Span) => a[0] < b[1] && b[0] < a[1];
/**
* One drawn bar per corridor a slot actually serves.
*
* A wagon reused across disjoint legs (containers Doraleh→Dire Dawa, bulk
* Dire Dawa→Gelan) is ONE slot whose stored board/alight yards are the UNION
* of its loads. Drawing that union as a single bar claims both loads ride the
* whole way and hides where each one actually sits. Each allocation carries
* its own booking yards, so group by corridor and draw one bar per group —
* the board then reads "containers on leg 1, bulk on leg 2" truthfully.
*
* Falls back to the slot's own span whenever the yards are missing or not on
* the stop list, which is exactly the previous behaviour.
*/
function splitByCorridor(slot: Slot, slotSpan: Span, stops: Stop[]): SlotPart[] {
const allocations = slot.allocations ?? [];
const whole: SlotPart[] = [
{ slot, span: slotSpan, loaded: allocations.length > 0, allocations },
];
if (allocations.length < 2) return whole;
const idx = (yardId?: string | null) =>
yardId ? stops.findIndex((s) => s.yardId === yardId) : -1;
const byCorridor = new Map<string, { span: Span; allocations: typeof allocations }>();
for (const allocation of allocations) {
const from = idx(allocation.originYardId);
const to = idx(allocation.destinationYardId);
// Any allocation without a usable corridor → keep the old single bar.
if (from < 0 || to <= from) return whole;
const key = `${from}-${to}`;
const entry = byCorridor.get(key);
if (entry) entry.allocations.push(allocation);
else byCorridor.set(key, { span: [from, to], allocations: [allocation] });
}
if (byCorridor.size < 2) return whole;
return [...byCorridor.values()]
.sort((a, b) => a.span[0] - b.span[0])
.map((part) => ({
slot,
span: part.span,
loaded: true,
allocations: part.allocations,
}));
}
/**
* Leg board: rows = physical wagons in coupling order, columns = corridor legs
* (A→B, B→C, …). A wagon reused on disjoint legs shows one load per leg on the
@@ -86,11 +140,7 @@ export function LegLoadBoardPanel({
row.position = Math.min(row.position, slot.position ?? slot.sequenceNo);
// Coupled-but-empty consist wagons carry no slot row: they are a target only.
if (!slot.consistOnly) {
row.slots.push({
slot,
span: spanOf(slot),
loaded: (slot.allocations?.length ?? 0) > 0,
});
row.slots.push(...splitByCorridor(slot, spanOf(slot), stops));
}
}
return [...byKey.values()].sort((a, b) => a.position - b.position);
@@ -211,15 +261,16 @@ export function LegLoadBoardPanel({
</Table.Thead>
<Table.Tbody>
{rows.map((row) => {
const cargoTons = row.slots.reduce(
(s, x) =>
s +
((x.slot.allocations ?? []).reduce(
(a, al) => a + (al.allocatedWeightTons ?? 0),
0,
) || x.slot.assignedWeightTons || 0),
0,
);
// Heaviest single leg, not the sum of every bar: one slot may be
// drawn as several corridor bars, and a wagon reused on disjoint
// legs never carries both loads at once. Summing them reported a
// 60T wagon as 120T loaded and painted the capacity red.
const cargoTons = row.slots.reduce((max, part) => {
const tons =
part.allocations.reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0) ||
(row.slots.length === 1 ? part.slot.assignedWeightTons || 0 : 0);
return Math.max(max, tons);
}, 0);
const isPickedRow = picked?.rowKey === row.key;
// A row can take the picked load when nothing loaded on it rides
// any of the picked load's legs.
@@ -279,12 +330,14 @@ export function LegLoadBoardPanel({
const isPicked = picked?.slotId === s.slot.id;
const swappable =
!!picked && !isPicked && !isPickedRow && s.loaded && canRearrange;
const allocs = s.slot.allocations ?? [];
// The allocations riding THIS bar's corridor — not the whole
// slot's, so a leg-shared wagon labels each leg with its own load.
const allocs = s.allocations;
const bulk = allocs.some((a) => (a.loadType ?? "CONTAINER").toUpperCase() === "BULK");
const containers = allocs.flatMap((a) => a.containerItems ?? []);
cells.push(
<Table.Td
key={s.slot.id}
key={`${s.slot.id}-${s.span[0]}-${s.span[1]}`}
colSpan={Math.max(1, s.span[1] - s.span[0])}
onClick={
!canRearrange

View File

@@ -39,11 +39,6 @@ interface InteractiveTrainConsistProps {
onMoveLoad?: (move: WagonLoadMove) => void;
}
const wagonItems = (wagon: Wagon) =>
(wagon.allocations ?? [])
.flatMap((a) => a.containerItems ?? [])
.sort((a, b) => (a.positionOnWagon ?? 99) - (b.positionOnWagon ?? 99));
const CONTAINER_GRADIENTS = [
"linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))",
"linear-gradient(180deg, var(--mantine-color-blue-5), var(--mantine-color-blue-7))",
@@ -192,7 +187,31 @@ function WagonCar({
}) {
const [dropHover, setDropHover] = useState(false);
const wagon = slots[0]!;
const loaded = slots.filter((s) => (s.allocations?.length ?? 0) > 0);
const loadedSlots = slots.filter((s) => (s.allocations?.length ?? 0) > 0);
// One drawn row per LOAD, not per slot. A wagon reused across disjoint legs
// (containers to Dire Dawa, bulk onward) is ONE slot holding two allocations
// with different corridors — counting slots drew that as a single row and
// hid the second load entirely. Group the slot's allocations by their own
// booking corridor so each load gets its own row, stacked top/bottom.
const loaded = loadedSlots.flatMap((slot) => {
const allocations = slot.allocations ?? [];
const byCorridor = new Map<string, typeof allocations>();
for (const allocation of allocations) {
const key =
allocation.originYardId && allocation.destinationYardId
? `${allocation.originYardId}->${allocation.destinationYardId}`
: "whole-route";
byCorridor.set(key, [...(byCorridor.get(key) ?? []), allocation]);
}
if (byCorridor.size < 2) {
return [{ slot, allocations, corridorKey: null as string | null }];
}
return [...byCorridor.entries()].map(([key, group]) => ({
slot,
allocations: group,
corridorKey: key as string | null,
}));
});
const shared = loaded.length > 1;
const isEmpty = !loaded.length;
const isBulk = loaded.some((s) =>
@@ -201,12 +220,15 @@ function WagonCar({
// GROSS on both sides: cargo across every slot + tare (counted ONCE — the
// slots share the same physical wagon) vs rated payload + tare.
const tare = wagon.tareWeightTons ?? 0;
// Heaviest single load, not the sum: rows on disjoint legs never ride at the
// same time, so summing them would over-report what the wagon carries.
const cargo = loaded.reduce(
(sum, s) =>
sum +
((s.allocations ?? []).reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0) ||
s.assignedWeightTons ||
0),
(max, row) =>
Math.max(
max,
row.allocations.reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0) ||
(loaded.length === 1 ? row.slot.assignedWeightTons || 0 : 0),
),
0,
);
const assigned = cargo + tare;
@@ -249,7 +271,7 @@ function WagonCar({
<HoverCard width={280} shadow="lg" radius="md" position="top" withArrow openDelay={120}>
<HoverCard.Target>
<Box
onClick={() => onSelectSlot(loaded[0] ?? wagon)}
onClick={() => onSelectSlot(loaded[0]?.slot ?? wagon)}
style={{ width: 148, flexShrink: 0, cursor: "pointer" }}
>
<Box
@@ -389,16 +411,25 @@ function WagonCar({
// two side by side. A shared wagon stacks its slots top/bottom
// (intercity above, export below); each row selects ITS slot.
<Stack gap={3} style={{ width: "100%" }}>
{loaded.map((slot, r) => {
const rowBulk = (slot.allocations ?? []).some((a) =>
{loaded.map((row, r) => {
const slot = row.slot;
const rowBulk = row.allocations.some((a) =>
(a.loadType ?? "").toUpperCase().includes("BULK"),
);
const rowBlocks = wagonItems(slot).slice(0, 2);
// Container blocks of THIS row's allocations only, so a
// leg-shared wagon shows each leg's own boxes.
const rowBlocks = row.allocations
.flatMap((a) => a.containerItems ?? [])
.slice()
.sort(
(a, b) => (a.positionOnWagon ?? 0) - (b.positionOnWagon ?? 0),
)
.slice(0, 2);
const rowSelected = shared && slot.id === selectedWagonId;
const rowHeight = shared ? 20 : 26;
return (
<Group
key={slot.id}
key={`${slot.id}-${row.corridorKey ?? "all"}`}
gap={3}
justify="center"
wrap="nowrap"
@@ -549,15 +580,18 @@ function WagonCar({
</Text>
) : (
<Stack gap={6}>
{loaded.map((slot) => {
const slotAllocation = slot.allocations?.[0];
{loaded.map((row) => {
const slot = row.slot;
const slotAllocation = row.allocations[0];
const slotCompany = getCompany(slotAllocation?.bookingId);
const slotContainers = wagonItems(slot).map(
(c) => c.containerNumber?.trim() || "—",
);
// This row's own containers, so a leg-shared wagon lists each
// leg's boxes under its own load rather than all of them twice.
const slotContainers = row.allocations
.flatMap((a) => a.containerItems ?? [])
.map((c) => c.containerNumber?.trim() || "—");
return (
<Stack
key={slot.id}
key={`${slot.id}-${row.corridorKey ?? "all"}`}
gap={4}
style={
shared

View File

@@ -55,9 +55,31 @@ interface WagonCancellation {
reason?: string | null;
rebookedAt?: string | null;
createdAt: string;
booking?: { id: string; reference: string; company?: { name: string } };
booking?: {
id: string;
reference: string;
customsClearingEnabled?: boolean;
company?: { name: string };
};
rebookedBooking?: { id: string; reference: string };
feeInvoice?: { invoiceNumber: string; status: string };
cancelledQuantities?: {
bySize?: Record<string, number>;
units?: Array<{
containerSize: string;
containerNumber: string;
sealNumber?: string | null;
vgmTons: number;
}>;
};
}
/** Editable rebook unit — prefilled from the cancelled snapshot. */
interface RebookUnitDraft {
containerSize: string;
containerNumber: string;
sealNumber: string;
vgmTons: number | "";
}
interface WagonCancellationListResponse {
@@ -116,6 +138,49 @@ export default function WagonCancellationsPage() {
const [from, setFrom] = useState<Date | null>(null);
const [to, setTo] = useState<Date | null>(null);
const [voiding, setVoiding] = useState<WagonCancellation | null>(null);
// GL rebook of a customs (Path B) credit: pick the day; container number /
// seal / VGM may be corrected. Non-customs credits are rebooked by the
// customer from the portal.
const canRebook = hasPermission(
user,
FREIGHT_PERMS.bookings.wagonCancellationRebook,
);
const [rebooking, setRebooking] = useState<WagonCancellation | null>(null);
const [rebookDate, setRebookDate] = useState<Date | null>(null);
const [rebookDrafts, setRebookDrafts] = useState<RebookUnitDraft[]>([]);
const openRebook = (r: WagonCancellation) => {
setRebooking(r);
setRebookDate(null);
setRebookDrafts(
(r.cancelledQuantities?.units ?? []).map((u) => ({
containerSize: u.containerSize,
containerNumber: u.containerNumber,
sealNumber: u.sealNumber ?? "",
vgmTons: Number(u.vgmTons) || "",
})),
);
};
const rebookContainersPayload = () => {
const bySize = new Map<string, RebookUnitDraft[]>();
for (const d of rebookDrafts) {
bySize.set(d.containerSize, [...(bySize.get(d.containerSize) ?? []), d]);
}
return [...bySize.entries()].map(([containerSize, units]) => ({
containerSize,
units: units.map((u) => ({
containerNumber: u.containerNumber.trim(),
...(u.sealNumber.trim() ? { sealNumber: u.sealNumber.trim() } : {}),
...(u.vgmTons !== "" ? { vgmTons: Number(u.vgmTons) } : {}),
})),
}));
};
const rebook = useMutation({
mutationFn: () =>
api.post(`/bookings/wagon-cancellations/${rebooking!.id}/rebook`, {
scheduledDate: toDayString(rebookDate!),
...(rebookDrafts.length ? { containers: rebookContainersPayload() } : {}),
}),
});
const resetPage = () =>
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
@@ -234,18 +299,39 @@ export default function WagonCancellationsPage() {
header: () => <span />,
cell: ({ row }) => {
const r = row.original;
if (r.status !== "FEE_PENDING" || !canVoid) return null;
const showVoid = r.status === "FEE_PENDING" && canVoid;
// Customs credits are GL's to rebook; non-customs ones the customer
// rebooks from the portal.
const showRebook =
r.status === "CREDIT_AVAILABLE" &&
canRebook &&
Boolean(r.booking?.customsClearingEnabled) &&
Number(r.creditAmount) > 0;
if (!showVoid && !showRebook) return null;
return (
<Group justify="flex-end" wrap="nowrap">
<Button
size="xs"
radius="md"
variant="subtle"
color="red"
onClick={() => setVoiding(r)}
>
Void
</Button>
{showRebook && (
<Button
size="xs"
radius="md"
variant="light"
color="green"
onClick={() => openRebook(r)}
>
Rebook
</Button>
)}
{showVoid && (
<Button
size="xs"
radius="md"
variant="subtle"
color="red"
onClick={() => setVoiding(r)}
>
Void
</Button>
)}
</Group>
);
},
@@ -391,6 +477,117 @@ export default function WagonCancellationsPage() {
</Stack>
)}
</Modal>
<Modal
opened={!!rebooking}
onClose={() => setRebooking(null)}
title="Rebook cancelled wagons"
centered
radius="md"
>
{rebooking && (
<Stack gap="sm">
<Text size="sm">
{rebooking.booking?.reference ?? rebooking.bookingId} ·{" "}
{rebooking.wagonsCancelled} wagon(s) · credit{" "}
{formatMoney(rebooking.creditAmount, rebooking.feeCurrency, 2)}
</Text>
<DatePickerInput
label="Shipment day"
placeholder="Pick the day"
value={rebookDate}
onChange={(v) => setRebookDate(v ? new Date(v) : null)}
radius="md"
/>
{rebookDrafts.length > 0 && (
<Stack gap={6}>
<Text size="xs" c="dimmed">
Correct the container details if they changed sizes and
quantities stay as cancelled.
</Text>
{rebookDrafts.map((d, i) => (
<Group key={i} gap={8} wrap="nowrap" align="flex-end">
<TextInput
label={`${d.containerSize} container`}
value={d.containerNumber}
onChange={(e) => {
const v = e.currentTarget.value;
setRebookDrafts((prev) =>
prev.map((x, idx) =>
idx === i ? { ...x, containerNumber: v } : x,
),
);
}}
size="xs"
radius="md"
style={{ flex: 1.4 }}
/>
<TextInput
label="Seal no."
value={d.sealNumber}
onChange={(e) => {
const v = e.currentTarget.value;
setRebookDrafts((prev) =>
prev.map((x, idx) =>
idx === i ? { ...x, sealNumber: v } : x,
),
);
}}
size="xs"
radius="md"
style={{ flex: 1 }}
/>
<TextInput
label="VGM (t)"
type="number"
value={d.vgmTons === "" ? "" : String(d.vgmTons)}
onChange={(e) => {
const raw = e.currentTarget.value;
setRebookDrafts((prev) =>
prev.map((x, idx) =>
idx === i
? { ...x, vgmTons: raw === "" ? "" : Number(raw) }
: x,
),
);
}}
size="xs"
radius="md"
style={{ width: 90 }}
/>
</Group>
))}
</Stack>
)}
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={() => setRebooking(null)}
>
Close
</Button>
<Button
color="green"
radius="md"
disabled={!rebookDate}
loading={rebook.isPending}
onClick={async () => {
try {
await rebook.mutateAsync();
toast.success("Credit rebooked as a new paid booking");
setRebooking(null);
void refetch();
} catch {
// interceptor surfaces the reason
}
}}
>
Rebook
</Button>
</Group>
</Stack>
)}
</Modal>
</PageContainer>
);
}

View File

@@ -95,12 +95,17 @@ function PayWindowCell({ deadline }: { deadline: string | null }) {
);
}
/** `invoices.type` of a wagon-cancellation fee — mirrors the API constant. */
const WAGON_CANCEL_FEE_INVOICE_TYPE = "WAGON_CANCEL_FEE";
/**
* "Confirm paid" for one row. Booking invoices are only confirmable while the
* booking's pay window is open (the API refuses otherwise): no window yet →
* no button; window closed → button disabled with the reason, and it flips
* live the second the countdown hits zero. Non-booking invoices (warehouse,
* clearance…) have no window and stay confirmable.
* clearance…) have no window and stay confirmable — and so do
* wagon-cancellation fees, which ride source=booking but are raised on an
* already-paid booking whose window has closed.
*/
function ConfirmCell({
row,
@@ -109,10 +114,11 @@ function ConfirmCell({
row: OfflineUsdInvoice;
onConfirm: (row: OfflineUsdInvoice) => void;
}) {
const deadline = row.booking?.paymentDeadline ?? null;
const feeInvoice = row.type === WAGON_CANCEL_FEE_INVOICE_TYPE;
const deadline = feeInvoice ? null : (row.booking?.paymentDeadline ?? null);
const now = useNow(deadline);
if (row.booking && !deadline) return null;
if (row.booking && !deadline && !feeInvoice) return null;
const closed = Boolean(deadline && new Date(deadline).getTime() <= now);
return (

View File

@@ -556,6 +556,13 @@ export interface TrainScheduleWagonAllocation {
allocatedWeightTons: number;
loadType?: string | null;
status?: string;
/**
* This load's OWN corridor. A wagon reused across disjoint legs carries two
* loads with different yards, so the wagon's boardYardId/alightYardId (their
* union) cannot say which load rides which leg — these can.
*/
originYardId?: string | null;
destinationYardId?: string | null;
containerItems?: Array<{
id: string;
containerNumber: string | null;

View File

@@ -26,6 +26,12 @@ import {
type WagonCancellationPreview,
} from "@/services/bookings.service";
import { OperationDatePicker } from "@/pages/bookings/clearance";
import {
RebookUnitsEditor,
containersFromDrafts,
draftsFromSnapshot,
type RebookUnitDraft,
} from "@/pages/bookings/RebookUnitsEditor";
import { useFeeInvoicePayment } from "@/pages/bookings/payments/useBookingPayment";
import type { BookingDetail } from "../booking-detail-types";
@@ -213,10 +219,18 @@ export function WagonCancellationCard({
});
const [rebookDate, setRebookDate] = useState("");
// Non-customs: container number / seal / VGM may change at rebook. Customs
// (Path B) credits are rebooked by GL from the backoffice instead.
const isCustoms = Boolean(booking.customsClearingEnabled);
const [rebookDrafts, setRebookDrafts] = useState<RebookUnitDraft[] | null>(null);
const snapshotUnits = creditRow?.cancelledQuantities?.units ?? [];
const drafts = rebookDrafts ?? draftsFromSnapshot(snapshotUnits);
const showUnitEditor = !isCustoms && drafts.length > 0;
const rebookMutation = useMutation({
mutationFn: () =>
bookingsService.rebookWagonCancellation(creditRow!.id, {
scheduledDate: rebookDate,
...(showUnitEditor ? { containers: containersFromDrafts(drafts) } : {}),
}),
onSuccess: ({ bookingId }) => {
toast.success("Wagons rebooked — taking you to the new booking.", {
@@ -290,22 +304,34 @@ export function WagonCancellationCard({
is available. Pick a shipment day to rebook them as a new paid
booking (no further payment needed).
</Alert>
<OperationDatePicker
bookingId={booking.id}
value={rebookDate}
onChange={setRebookDate}
/>
<Group justify="flex-end">
<Button
color="edr-green"
radius="md"
disabled={!rebookDate}
loading={rebookMutation.isPending}
onClick={() => rebookMutation.mutate()}
>
Rebook wagons
</Button>
</Group>
{isCustoms ? (
<Text fz={13} c="#475569">
This is a customs-cleared booking Global Logistics will rebook
the credit for you.
</Text>
) : (
<>
<OperationDatePicker
bookingId={booking.id}
value={rebookDate}
onChange={setRebookDate}
/>
{showUnitEditor && (
<RebookUnitsEditor drafts={drafts} onChange={setRebookDrafts} />
)}
<Group justify="flex-end">
<Button
color="edr-green"
radius="md"
disabled={!rebookDate}
loading={rebookMutation.isPending}
onClick={() => rebookMutation.mutate()}
>
Rebook wagons
</Button>
</Group>
</>
)}
</Stack>
) : (
<Text fz={13} c="#475569">

View File

@@ -194,11 +194,13 @@ function PrimaryAction({
const { status, id } = booking;
const go = () => onNavigate(`/bookings/${id}`);
// Cancelled wagons with a paid credit (partial or whole cancel) → rebook.
if (credit) {
// Customs (Path B) credits are rebooked by GL from the backoffice, not here.
if (credit && !booking.customsClearingEnabled) {
return (
<RebookWagonsButton
cancellation={credit}
currency={booking.paymentCurrency}
editableUnits
/>
);
}

View File

@@ -0,0 +1,99 @@
import { Box, Group, NumberInput, Text, TextInput } from "@mantine/core";
/** One editable rebook unit — prefilled from the cancelled snapshot. */
export interface RebookUnitDraft {
containerSize: string;
containerNumber: string;
sealNumber: string;
vgmTons: number | "";
}
/** Snapshot units → editable drafts (the initial editor state). */
export function draftsFromSnapshot(
units: Array<{
containerSize: string;
containerNumber: string;
sealNumber?: string | null;
vgmTons: number;
}>,
): RebookUnitDraft[] {
return units.map((u) => ({
containerSize: u.containerSize,
containerNumber: u.containerNumber,
sealNumber: u.sealNumber ?? "",
vgmTons: Number(u.vgmTons) || "",
}));
}
/** Drafts → the rebook payload's containers field (grouped by size). */
export function containersFromDrafts(drafts: RebookUnitDraft[]) {
const bySize = new Map<string, RebookUnitDraft[]>();
for (const d of drafts) {
bySize.set(d.containerSize, [...(bySize.get(d.containerSize) ?? []), d]);
}
return [...bySize.entries()].map(([containerSize, units]) => ({
containerSize,
units: units.map((u) => ({
containerNumber: u.containerNumber.trim(),
...(u.sealNumber.trim() ? { sealNumber: u.sealNumber.trim() } : {}),
...(u.vgmTons !== "" ? { vgmTons: Number(u.vgmTons) } : {}),
})),
}));
}
/**
* Per-unit editor for a rebook: container number, seal and VGM may change;
* sizes and quantities are fixed by the credit, so rows can't be added or
* removed.
*/
export function RebookUnitsEditor({
drafts,
onChange,
}: {
drafts: RebookUnitDraft[];
onChange: (next: RebookUnitDraft[]) => void;
}) {
const set = (i: number, patch: Partial<RebookUnitDraft>) =>
onChange(drafts.map((d, idx) => (idx === i ? { ...d, ...patch } : d)));
return (
<Box>
<Text fz={12.5} c="#475569" mb={6}>
Update the container details if they changed the sizes and quantities
stay as cancelled.
</Text>
{drafts.map((d, i) => (
<Group key={i} gap={8} wrap="nowrap" mb={8} align="flex-end">
<TextInput
label={i === 0 || drafts[i - 1].containerSize !== d.containerSize ? `${d.containerSize} container` : " "}
placeholder="Container no."
value={d.containerNumber}
onChange={(e) => set(i, { containerNumber: e.currentTarget.value })}
radius={8}
size="xs"
style={{ flex: 1.4 }}
/>
<TextInput
label={i === 0 ? "Seal no." : " "}
placeholder="Seal no."
value={d.sealNumber}
onChange={(e) => set(i, { sealNumber: e.currentTarget.value })}
radius={8}
size="xs"
style={{ flex: 1 }}
/>
<NumberInput
label={i === 0 ? "VGM (t)" : " "}
placeholder="VGM"
value={d.vgmTons}
onChange={(v) => set(i, { vgmTons: typeof v === "number" ? v : "" })}
min={0}
radius={8}
size="xs"
style={{ width: 90 }}
/>
</Group>
))}
</Box>
);
}

View File

@@ -9,6 +9,12 @@ import { api } from "@/services/api";
import { bookingsService, type WagonCancellation } from "@/services/bookings.service";
import { OperationDatePicker } from "./clearance";
import { formatAmount } from "./BookingDetailPage/utils";
import {
RebookUnitsEditor,
containersFromDrafts,
draftsFromSnapshot,
type RebookUnitDraft,
} from "./RebookUnitsEditor";
const apiErrorMessage = (error: unknown, fallback: string) => {
const data = (error as { response?: { data?: { message?: string | string[] } } })
@@ -26,19 +32,30 @@ export function RebookWagonsButton({
cancellation,
currency,
size = "xs",
editableUnits,
}: {
cancellation: WagonCancellation;
currency?: string;
size?: "xs" | "sm";
/** Non-customs contracts: container number / seal / VGM may be edited at rebook. */
editableUnits?: boolean;
}) {
const navigate = useNavigate();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [date, setDate] = useState("");
const snapshotUnits = cancellation.cancelledQuantities?.units ?? [];
const [drafts, setDrafts] = useState<RebookUnitDraft[]>(() =>
draftsFromSnapshot(snapshotUnits),
);
const showEditor = Boolean(editableUnits) && drafts.length > 0;
const rebook = useMutation({
mutationFn: () =>
bookingsService.rebookWagonCancellation(cancellation.id, { scheduledDate: date }),
bookingsService.rebookWagonCancellation(cancellation.id, {
scheduledDate: date,
...(showEditor ? { containers: containersFromDrafts(drafts) } : {}),
}),
onSuccess: ({ bookingId }) => {
qc.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
qc.invalidateQueries({ queryKey: api.bookings.listMyWagonCancellations.queryKey() });
@@ -87,6 +104,9 @@ export function RebookWagonsButton({
value={date}
onChange={setDate}
/>
{showEditor && (
<RebookUnitsEditor drafts={drafts} onChange={setDrafts} />
)}
<Group justify="flex-end">
<Button variant="default" radius="md" onClick={() => setOpen(false)}>
Close

View File

@@ -815,7 +815,18 @@ export const bookingsService = {
/** Rebook a CREDIT_AVAILABLE cancellation onto a shipment day → new PAID booking. */
rebookWagonCancellation: async (
cancellationId: string,
payload: { scheduledDate: string },
payload: {
scheduledDate: string;
/** Optional unit edits — sizes/quantities must match the credit exactly. */
containers?: Array<{
containerSize: string;
units: Array<{
containerNumber: string;
sealNumber?: string;
vgmTons?: number;
}>;
}>;
},
): Promise<{ cancellation: WagonCancellation; bookingId: string }> => {
const { data } = await client.post(
`/api/bookings/wagon-cancellations/${cancellationId}/rebook`,