mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1387 from Tria-plc/freight_feature/usermanagement
fix issue
This commit is contained in:
339
apps/edr-freight-api/scripts/run-legboard-tests.ts
Normal file
339
apps/edr-freight-api/scripts/run-legboard-tests.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* 10 scenarios against the REAL S-2026-00045 consist (42 NW5 + 10 PW2) on the
|
||||
* real DCT -> DIRE_DAWA -> GMP corridor, checking the three reported issues:
|
||||
*
|
||||
* 1. LEG BOARD truthfulness — can you tell which booking rides which leg?
|
||||
* 2. CONSIST DIAGRAM — does a shared wagon expose one row per load?
|
||||
* 3. PARTIAL OFFER — when only the 10 PW2 are free, is the customer offered
|
||||
* the part that fits instead of being dropped silently?
|
||||
*
|
||||
* Read-only. Run: npx ts-node -T scripts/run-legboard-tests.ts
|
||||
*/
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import {
|
||||
planWagonsWithStock,
|
||||
type AllowedWagonTypeMap,
|
||||
type WagonStock,
|
||||
} from '../src/modules/train-scheduling/wagon-plan-flex.util';
|
||||
import { validateWagonCargoExclusivity } from '../src/modules/train-scheduling/utils/wagon-plan.util';
|
||||
import { sizePartialOfferWagons, bulkTonsPerWagon } from '../src/modules/train-scheduling/train-capacity.util';
|
||||
import type { Booking } from '../src/modules/bookings/entities/booking.entity';
|
||||
import type { WagonType } from '../src/modules/wagon-types/entities/wagon-type.entity';
|
||||
|
||||
const YARD = {
|
||||
DCT: 'fc558b95-da28-4fc3-8348-311a290c34ae',
|
||||
DIRE: 'f7b1686f-d43e-42aa-bc6a-d3d5849296c9',
|
||||
GMP: '61ae1e66-c229-4dcd-9851-b2b9424f3a95',
|
||||
};
|
||||
const STOPS = [YARD.DCT, YARD.DIRE, YARD.GMP];
|
||||
const STOP_NAME = ['DCT', 'DIRE', 'GMP'];
|
||||
const NW5 = '8f717b09-eec1-46ad-be3d-2dc0a56e55e7';
|
||||
const PW2 = '8eec3a7d-8482-4397-96b6-59a028210722';
|
||||
const PERISHABLE = 'a5991d3a-d690-4b7e-98fd-ea3333aa16e7';
|
||||
const FT40 = '349072e7-8a90-4c03-b682-08976abfd7e8';
|
||||
|
||||
const ds = new DataSource({
|
||||
type: 'postgres',
|
||||
host: process.env.DB_HOST || '10.18.7.207',
|
||||
port: Number(process.env.DB_PORT || 5432),
|
||||
database: process.env.DB_NAME || 'edr_dev',
|
||||
username: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'dcba@1234',
|
||||
});
|
||||
|
||||
type Check = { name: string; pass: boolean; detail: string };
|
||||
const groups: Array<{ scenario: string; checks: Check[] }> = [];
|
||||
const add = (l: Check[], name: string, pass: boolean, detail: string) =>
|
||||
l.push({ name, pass, detail });
|
||||
|
||||
let TYPES: WagonType[] = [];
|
||||
let ALLOWED: AllowedWagonTypeMap;
|
||||
let CODES = new Map<string, string>();
|
||||
let CARGO: any;
|
||||
|
||||
async function loadConfig() {
|
||||
TYPES = await ds.query(
|
||||
`SELECT id, code, name, capacity_tons AS "capacityTons",
|
||||
length_meters AS "lengthMeters", tare_weight_tons AS "tareWeightTons",
|
||||
supports_container AS "supportsContainer", is_active AS "isActive"
|
||||
FROM freight.wagon_types WHERE is_active IS NOT FALSE`);
|
||||
const byId = new Map(TYPES.map((t) => [t.id, t]));
|
||||
CODES = new Map(TYPES.map((t) => [t.id, t.code]));
|
||||
const rows = async (sql: string) => {
|
||||
const r: Array<{ typeId: string; wagonTypeId: string }> = await ds.query(sql);
|
||||
const m = new Map<string, WagonType[]>();
|
||||
for (const x of r) {
|
||||
const wt = byId.get(x.wagonTypeId);
|
||||
if (wt) m.set(x.typeId, [...(m.get(x.typeId) ?? []), wt]);
|
||||
}
|
||||
return m;
|
||||
};
|
||||
ALLOWED = {
|
||||
byCargoTypeId: await rows(
|
||||
`SELECT cargo_type_id AS "typeId", wagon_type_id AS "wagonTypeId" FROM freight.cargo_type_wagon_types`),
|
||||
byContainerTypeId: await rows(
|
||||
`SELECT container_type_id AS "typeId", wagon_type_id AS "wagonTypeId" FROM freight.container_type_wagon_types`),
|
||||
};
|
||||
const [c] = await ds.query(
|
||||
`SELECT id, cargo_type_name AS "cargoTypeName", code, unit_of_measure AS "unitOfMeasure",
|
||||
tons_per_wagon_map AS "tonsPerWagonMap", items_per_wagon_map AS "itemsPerWagonMap"
|
||||
FROM freight.cargo_types WHERE id=$1`, [PERISHABLE]);
|
||||
CARGO = { ...c, wagonTypes: ALLOWED.byCargoTypeId.get(PERISHABLE) ?? [] };
|
||||
}
|
||||
|
||||
let seq = 0;
|
||||
const bulk = (ref: string, tons: number, from: string, to: string, paid = false): Booking =>
|
||||
({
|
||||
id: `bulk-${(seq += 1)}`, reference: ref, freightType: 'BULK',
|
||||
cargoTotalWeightVgm: tons, cargoTypeId: PERISHABLE, cargoType: CARGO,
|
||||
originYardId: from, destinationYardId: to, bookingContainers: [],
|
||||
status: paid ? 'PAID' : 'CLEARANCE_READY',
|
||||
paymentStatus: paid ? 'PAID' : 'PENDING',
|
||||
}) as unknown as Booking;
|
||||
|
||||
const cont = (ref: string, qty: number, from: string, to: string, paid = false): Booking =>
|
||||
({
|
||||
id: `cont-${(seq += 1)}`, reference: ref, freightType: 'CONTAINER',
|
||||
cargoTotalWeightVgm: qty * 26, originYardId: from, destinationYardId: to,
|
||||
status: paid ? 'PAID' : 'CLEARANCE_READY',
|
||||
paymentStatus: paid ? 'PAID' : 'PENDING',
|
||||
bookingContainers: [{
|
||||
id: `line-${seq}`, containerTypeId: FT40, quantity: qty,
|
||||
vgmPerUnitTons: 26, wagonsRequired: qty,
|
||||
containerType: { id: FT40, code: '40FT', sizeFt: 40 }, units: [],
|
||||
}],
|
||||
}) as unknown as Booking;
|
||||
|
||||
const stockOf = (nw5: number, pw2: number): WagonStock => ({
|
||||
mode: 'TRAIN',
|
||||
remainingByTypeId: new Map([[NW5, nw5], [PW2, pw2]]),
|
||||
codesByTypeId: CODES,
|
||||
});
|
||||
|
||||
const legsOf = (bs: Booking[]) =>
|
||||
new Map(bs.map((b: any) => [b.id,
|
||||
{ from: STOPS.indexOf(b.originYardId), to: STOPS.indexOf(b.destinationYardId) }]));
|
||||
|
||||
const plan = (bs: Booking[], stock: WagonStock) =>
|
||||
planWagonsWithStock({
|
||||
bookings: bs, allowed: ALLOWED, stock, legs: legsOf(bs),
|
||||
edgeCount: 2, stops: STOPS,
|
||||
});
|
||||
|
||||
const byType = (p: any[]) => {
|
||||
const c = new Map<string, number>();
|
||||
for (const s of p) c.set(s.wagonTypeCode, (c.get(s.wagonTypeCode) ?? 0) + 1);
|
||||
return [...c.entries()].map(([k, n]) => `${n}x ${k}`).join(', ') || 'none';
|
||||
};
|
||||
|
||||
/**
|
||||
* What the LEG BOARD can actually say about a slot, given only the fields it
|
||||
* reads (boardYardId / alightYardId) — this is the UI's own view.
|
||||
*/
|
||||
const stampSpan = (slot: any, bs: Booking[]) => {
|
||||
const byId = new Map(bs.map((b: any) => [b.id, b]));
|
||||
const sb = [...new Set(slot.allocations.map((a: any) => a.bookingId))]
|
||||
.map((id) => byId.get(id as string)).filter(Boolean) as any[];
|
||||
if (!sb.length) return null;
|
||||
const first = sb[0];
|
||||
const same = sb.every((b) =>
|
||||
b.originYardId === first.originYardId && b.destinationYardId === first.destinationYardId);
|
||||
if (same) {
|
||||
return {
|
||||
from: STOPS.indexOf(first.originYardId),
|
||||
to: STOPS.indexOf(first.destinationYardId),
|
||||
union: false,
|
||||
};
|
||||
}
|
||||
let from = Infinity, to = -Infinity;
|
||||
for (const b of sb) {
|
||||
from = Math.min(from, STOPS.indexOf(b.originYardId));
|
||||
to = Math.max(to, STOPS.indexOf(b.destinationYardId));
|
||||
}
|
||||
return { from, to, union: true };
|
||||
};
|
||||
|
||||
async function main() {
|
||||
await ds.initialize();
|
||||
await loadConfig();
|
||||
|
||||
console.log('\n=== REAL CONSIST (S-2026-00045): 42 NW5 + 10 PW2 ===');
|
||||
console.log('Corridor: DCT -> DIRE -> GMP (edge 0 = DCT->DIRE, edge 1 = DIRE->GMP)\n');
|
||||
|
||||
// ============================================================ SCENARIO 1
|
||||
// The reported shape: 42x40ft intercity Dire->GMP + bulk DCT->GMP.
|
||||
{
|
||||
const checks: Check[] = [];
|
||||
const bs = [cont('IC-42', 42, YARD.DIRE, YARD.GMP), bulk('BULK-695', 695, YARD.DCT, YARD.GMP, true)];
|
||||
const r = plan(bs, stockOf(42, 10));
|
||||
console.log('── S1: 42x40ft Dire->GMP (intercity) + 695T bulk DCT->GMP (paid)');
|
||||
console.log(` plan ${r.plan.length} wagons — ${byType(r.plan)}`);
|
||||
for (const d of r.deferred) console.log(` DEFERRED ${d.reference}: ${d.reason}`);
|
||||
|
||||
// Every slot must say, truthfully, which legs it is busy on.
|
||||
const shared = r.plan.filter((s) => {
|
||||
const refs = new Set(s.allocations.map((a) => a.bookingReference));
|
||||
return refs.size > 1;
|
||||
});
|
||||
add(checks, 'shared wagons expose EVERY load (diagram can stack them)',
|
||||
shared.every((s) => s.allocations.length >= 2),
|
||||
`${shared.length} shared wagon(s); allocations per shared wagon: ${
|
||||
shared.map((s) => s.allocations.length).join(',') || 'n/a'}`);
|
||||
|
||||
// The leg-board reads ONE span per slot. For a mixed-corridor wagon that
|
||||
// span is the UNION, which cannot say which load rides which leg.
|
||||
const mixed = r.plan.map((s) => stampSpan(s, bs)).filter((x) => x?.union);
|
||||
add(checks, 'no wagon needs a UNION span (leg board stays truthful)',
|
||||
mixed.length === 0,
|
||||
mixed.length
|
||||
? `${mixed.length} wagon(s) carry different corridors -> leg board shows one merged bar`
|
||||
: 'every wagon carries a single corridor');
|
||||
add(checks, 'per-edge exclusivity holds',
|
||||
validateWagonCargoExclusivity(r.plan, legsOf(bs), 2).length === 0, 'clean');
|
||||
groups.push({ scenario: 'S1 — intercity 42x40ft + paid bulk (the reported case)', checks });
|
||||
}
|
||||
|
||||
// ============================================================ SCENARIO 2
|
||||
// Only the 10 PW2 are free — the staging complaint.
|
||||
{
|
||||
const checks: Check[] = [];
|
||||
const b = bulk('BULK-695', 695, YARD.DCT, YARD.GMP, true);
|
||||
const r = plan([b], stockOf(0, 10));
|
||||
console.log('\n── S2: 695T bulk, ONLY 10 PW2 free (0 NW5)');
|
||||
console.log(` plan ${r.plan.length} wagons — ${byType(r.plan)}`);
|
||||
for (const d of r.deferred) console.log(` DEFERRED ${d.reference}: ${d.reason}`);
|
||||
|
||||
add(checks, 'whole booking correctly refused (10 PW2 x 20T = 200T < 695T)',
|
||||
r.deferred.length === 1 && r.fitting.length === 0,
|
||||
r.deferred[0]?.reason ?? 'unexpectedly fitted');
|
||||
|
||||
// What SHOULD happen: offer the part that fits on those 10 PW2.
|
||||
const perWagonTons = bulkTonsPerWagon(CARGO, PW2, 70);
|
||||
const pw2Type = TYPES.find((t) => t.id === PW2)!;
|
||||
const wholeWagons = Math.ceil(695 / perWagonTons);
|
||||
const offer = sizePartialOfferWagons(
|
||||
{ wagons: 10, weightTons: 3500, lengthMeters: 1520 },
|
||||
wholeWagons,
|
||||
{ capacityTons: perWagonTons,
|
||||
tareWeightTons: Number(pw2Type.tareWeightTons),
|
||||
lengthMeters: Number(pw2Type.lengthMeters) },
|
||||
{ fullWagonsOnly: true },
|
||||
);
|
||||
console.log(` partial offer sizing: ${offer ? `${offer.wagons} PW2 = ${offer.maxCargoTons}T of 695T` : 'NONE'}`);
|
||||
add(checks, 'a partial offer of 10 PW2 (200T) is sizeable for the customer',
|
||||
offer != null && offer.wagons === 10 && offer.maxCargoTons === 200,
|
||||
offer ? `${offer.wagons} PW2 x ${perWagonTons}T = ${offer.maxCargoTons}T` : 'no offer could be sized');
|
||||
groups.push({ scenario: 'S2 — only 10 PW2 free: split offer instead of silent drop', checks });
|
||||
}
|
||||
|
||||
// ============================================================ SCENARIO 3-10
|
||||
const cases: Array<{ name: string; bookings: Booking[]; stock: WagonStock; expectFit?: string[] }> = [
|
||||
{ name: 'S3 — bulk DCT->DIRE + bulk DIRE->GMP (disjoint, reuse expected)',
|
||||
bookings: [bulk('B-L1', 200, YARD.DCT, YARD.DIRE), bulk('B-L2', 200, YARD.DIRE, YARD.GMP)],
|
||||
stock: stockOf(7, 0) },
|
||||
{ name: 'S4 — container DCT->DIRE then bulk DIRE->GMP on same wagons',
|
||||
bookings: [cont('C-L1', 7, YARD.DCT, YARD.DIRE), bulk('B-L2', 210, YARD.DIRE, YARD.GMP)],
|
||||
stock: stockOf(7, 0) },
|
||||
{ name: 'S5 — three corridors at once (DCT->DIRE, DIRE->GMP, DCT->GMP)',
|
||||
bookings: [cont('C-A', 5, YARD.DCT, YARD.DIRE), cont('C-B', 5, YARD.DIRE, YARD.GMP),
|
||||
bulk('B-FULL', 150, YARD.DCT, YARD.GMP, true)],
|
||||
stock: stockOf(12, 10) },
|
||||
{ name: 'S6 — paid bulk vs unpaid containers, scarce NW5',
|
||||
bookings: [cont('C-UNPAID', 20, YARD.DCT, YARD.GMP), bulk('B-PAID', 300, YARD.DCT, YARD.GMP, true)],
|
||||
stock: stockOf(20, 10) },
|
||||
{ name: 'S7 — two paid bulks competing for the same PW2',
|
||||
bookings: [bulk('B-P1', 200, YARD.DCT, YARD.GMP, true), bulk('B-P2', 200, YARD.DCT, YARD.GMP, true)],
|
||||
stock: stockOf(5, 10) },
|
||||
{ name: 'S8 — bulk exactly filling the PW2 (200T)',
|
||||
bookings: [bulk('B-EXACT', 200, YARD.DCT, YARD.GMP, true)],
|
||||
stock: stockOf(0, 10) },
|
||||
{ name: 'S9 — 1T over the PW2 capacity (201T)',
|
||||
bookings: [bulk('B-OVER', 201, YARD.DCT, YARD.GMP, true)],
|
||||
stock: stockOf(0, 10) },
|
||||
{ name: 'S10 — full train: containers both legs + bulk through',
|
||||
bookings: [cont('C-1', 42, YARD.DCT, YARD.DIRE), cont('C-2', 42, YARD.DIRE, YARD.GMP),
|
||||
bulk('B-THRU', 200, YARD.DCT, YARD.GMP, true)],
|
||||
stock: stockOf(42, 10) },
|
||||
];
|
||||
|
||||
for (const c of cases) {
|
||||
const checks: Check[] = [];
|
||||
const r = plan(c.bookings, c.stock);
|
||||
const legs = legsOf(c.bookings);
|
||||
console.log(`\n── ${c.name}`);
|
||||
console.log(` plan ${r.plan.length} wagons — ${byType(r.plan)}`);
|
||||
console.log(` fitting: ${r.fitting.map((b) => b.reference).join(', ') || 'none'}`);
|
||||
for (const d of r.deferred) console.log(` DEFERRED ${d.reference}: ${d.reason}`);
|
||||
for (const s of r.plan.filter((x) => x.allocations.length > 1)) {
|
||||
const spans = s.allocations.map((a) => {
|
||||
const l = legs.get(a.bookingId)!;
|
||||
return `${a.bookingReference}[${STOP_NAME[l.from]}->${STOP_NAME[l.to]}]`;
|
||||
});
|
||||
console.log(` wagon #${s.sequenceNo} ${s.wagonTypeCode}: ${spans.join(' + ')}`);
|
||||
}
|
||||
|
||||
add(checks, 'per-edge cargo exclusivity holds',
|
||||
validateWagonCargoExclusivity(r.plan, legs, 2).length === 0,
|
||||
validateWagonCargoExclusivity(r.plan, legs, 2).join('; ') || 'clean');
|
||||
add(checks, 'every planned wagon carries at least one allocation',
|
||||
r.plan.every((s) => s.allocations.length > 0),
|
||||
`${r.plan.filter((s) => !s.allocations.length).length} empty slot(s)`);
|
||||
// Per-EDGE cap: a wagon reused on two disjoint legs carries its cap on
|
||||
// each leg, so assignedWeightTons (the whole-journey sum) may exceed one
|
||||
// leg's cap legitimately. Check the heaviest single leg instead.
|
||||
const overCap = r.plan.filter((s) => s.slotLoadType === 'BULK').filter((s) => {
|
||||
const cap = bulkTonsPerWagon(CARGO, s.wagonTypeId, Number(s.capacityTons));
|
||||
const perEdge = [0, 0];
|
||||
for (const a of s.allocations) {
|
||||
const l = legs.get(a.bookingId)!;
|
||||
for (let e = l.from; e < l.to; e += 1) perEdge[e] += a.allocatedWeightTons;
|
||||
}
|
||||
return Math.max(...perEdge) > cap + 0.001;
|
||||
});
|
||||
add(checks, 'no bulk wagon exceeds its cargo cap on any single leg',
|
||||
overCap.length === 0,
|
||||
overCap.length ? `${overCap.length} wagon(s) over cap` : 'caps respected per leg');
|
||||
// Leg-board truthfulness for every shared wagon.
|
||||
// The leg board no longer relies on the slot's union span: each allocation
|
||||
// carries its booking's own yards, so the UI splits a shared wagon into one
|
||||
// bar per corridor. Assert that data IS derivable for every shared wagon.
|
||||
const unionSpans = r.plan.map((s) => stampSpan(s, c.bookings)).filter((x) => x?.union);
|
||||
const splittable = r.plan
|
||||
.filter((s) => s.allocations.length > 1)
|
||||
.every((s) => {
|
||||
const corridors = new Set(
|
||||
s.allocations.map((a) => {
|
||||
const l = legs.get(a.bookingId);
|
||||
return l ? `${l.from}-${l.to}` : 'unknown';
|
||||
}),
|
||||
);
|
||||
return !corridors.has('unknown');
|
||||
});
|
||||
add(checks, 'leg board can name the leg of every load (per-allocation corridors)',
|
||||
splittable,
|
||||
unionSpans.length
|
||||
? `${unionSpans.length} wagon(s) span a union — UI splits them into ${
|
||||
[...new Set(r.plan.flatMap((s) => s.allocations.map((a) => {
|
||||
const l = legs.get(a.bookingId)!;
|
||||
return `${STOP_NAME[l.from]}->${STOP_NAME[l.to]}`;
|
||||
})))].join(' | ')} bars`
|
||||
: 'no shared wagons; spans already exact');
|
||||
groups.push({ scenario: c.name, checks });
|
||||
}
|
||||
|
||||
console.log('\n\n================ REPORT ================');
|
||||
let pass = 0, fail = 0;
|
||||
for (const g of groups) {
|
||||
console.log(`\n${g.scenario}`);
|
||||
for (const c of g.checks) {
|
||||
console.log(` ${c.pass ? 'PASS' : 'FAIL'} ${c.name}\n ${c.detail}`);
|
||||
c.pass ? (pass += 1) : (fail += 1);
|
||||
}
|
||||
}
|
||||
console.log(`\n----------------------------------------`);
|
||||
console.log(`TOTAL: ${pass} passed, ${fail} failed`);
|
||||
await ds.destroy();
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
236
apps/edr-freight-api/scripts/run-s45-scenario.ts
Normal file
236
apps/edr-freight-api/scripts/run-s45-scenario.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Reproduces the reported S-2026-00045 failure: three DCT->Dire container
|
||||
* bookings + one Dire->GMP container booking are allocated, but the
|
||||
* DCT->GMP 695T bulk booking is never selected even though PW2 and NW5
|
||||
* wagons are free.
|
||||
*
|
||||
* Runs the REAL planner against the REAL 42 NW5 + 10 PW2 consist, in several
|
||||
* booking orders (= different window cycles / arrival orders).
|
||||
*
|
||||
* npx ts-node -T scripts/run-s45-scenario.ts
|
||||
*/
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import {
|
||||
planWagonsWithStock,
|
||||
type AllowedWagonTypeMap,
|
||||
type WagonStock,
|
||||
} from '../src/modules/train-scheduling/wagon-plan-flex.util';
|
||||
import { validateWagonCargoExclusivity } from '../src/modules/train-scheduling/utils/wagon-plan.util';
|
||||
import type { Booking } from '../src/modules/bookings/entities/booking.entity';
|
||||
import type { WagonType } from '../src/modules/wagon-types/entities/wagon-type.entity';
|
||||
|
||||
const YARD = {
|
||||
DCT: 'fc558b95-da28-4fc3-8348-311a290c34ae',
|
||||
DIRE: 'f7b1686f-d43e-42aa-bc6a-d3d5849296c9',
|
||||
GMP: '61ae1e66-c229-4dcd-9851-b2b9424f3a95',
|
||||
};
|
||||
const STOPS = [YARD.DCT, YARD.DIRE, YARD.GMP];
|
||||
const NW5 = '8f717b09-eec1-46ad-be3d-2dc0a56e55e7';
|
||||
const PW2 = '8eec3a7d-8482-4397-96b6-59a028210722';
|
||||
const TRAIN_ID = '56eec969-55ec-4bc5-aad6-f4b7b6d17292';
|
||||
|
||||
const ds = new DataSource({
|
||||
type: 'postgres',
|
||||
host: process.env.DB_HOST || '10.18.7.207',
|
||||
port: Number(process.env.DB_PORT || 5432),
|
||||
database: process.env.DB_NAME || 'edr_dev',
|
||||
username: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'dcba@1234',
|
||||
});
|
||||
|
||||
async function loadConfig(): Promise<{
|
||||
allowed: AllowedWagonTypeMap;
|
||||
codes: Map<string, string>;
|
||||
}> {
|
||||
const types: WagonType[] = await ds.query(
|
||||
`SELECT id, code, name, capacity_tons AS "capacityTons",
|
||||
length_meters AS "lengthMeters", tare_weight_tons AS "tareWeightTons",
|
||||
supports_container AS "supportsContainer", is_active AS "isActive"
|
||||
FROM freight.wagon_types WHERE is_active IS NOT FALSE`,
|
||||
);
|
||||
const byId = new Map(types.map((t) => [t.id, t]));
|
||||
const rows = async (sql: string) => {
|
||||
const r: Array<{ typeId: string; wagonTypeId: string }> = await ds.query(sql);
|
||||
const map = new Map<string, WagonType[]>();
|
||||
for (const row of r) {
|
||||
const wt = byId.get(row.wagonTypeId);
|
||||
if (wt) map.set(row.typeId, [...(map.get(row.typeId) ?? []), wt]);
|
||||
}
|
||||
return map;
|
||||
};
|
||||
return {
|
||||
allowed: {
|
||||
byCargoTypeId: await rows(
|
||||
`SELECT cargo_type_id AS "typeId", wagon_type_id AS "wagonTypeId" FROM freight.cargo_type_wagon_types`),
|
||||
byContainerTypeId: await rows(
|
||||
`SELECT container_type_id AS "typeId", wagon_type_id AS "wagonTypeId" FROM freight.container_type_wagon_types`),
|
||||
},
|
||||
codes: new Map(types.map((t) => [t.id, t.code])),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadBookings(allowed: AllowedWagonTypeMap): Promise<Map<string, Booking>> {
|
||||
const rows = await ds.query(
|
||||
`SELECT b.id, b.reference, b.freight_type AS "freightType",
|
||||
b.cargo_total_weight_vgm AS "cargoTotalWeightVgm",
|
||||
b.bulk_total_weight_tons AS "bulkTotalWeightTons",
|
||||
b.cargo_type_id AS "cargoTypeId", b.origin_yard_id AS "originYardId",
|
||||
b.destination_yard_id AS "destinationYardId",
|
||||
b.is_government AS "isGovernment", b.priority_score AS "priorityScore",
|
||||
b.status, b.payment_status AS "paymentStatus",
|
||||
b.created_at AS "createdAt"
|
||||
FROM freight.bookings b
|
||||
WHERE b.reference LIKE 'SF45-%' AND b.deleted_at IS NULL`);
|
||||
const cargo = await ds.query(
|
||||
`SELECT id, cargo_type_name AS "cargoTypeName", code,
|
||||
unit_of_measure AS "unitOfMeasure",
|
||||
tons_per_wagon_map AS "tonsPerWagonMap",
|
||||
items_per_wagon_map AS "itemsPerWagonMap" FROM freight.cargo_types`);
|
||||
const cargoById = new Map(cargo.map((c: any) => [c.id, c]));
|
||||
const lines = await ds.query(
|
||||
`SELECT bc.id, bc.booking_id AS "bookingId", bc.container_type_id AS "containerTypeId",
|
||||
bc.quantity, bc.vgm_per_unit_tons AS "vgmPerUnitTons",
|
||||
bc.wagons_required AS "wagonsRequired", ct.code, ct.size_ft AS "sizeFt"
|
||||
FROM freight.booking_container bc
|
||||
JOIN freight.container_types ct ON ct.id = bc.container_type_id
|
||||
WHERE bc.booking_id = ANY($1) AND bc.deleted_at IS NULL`,
|
||||
[rows.map((r: any) => r.id)]);
|
||||
|
||||
return new Map(rows.map((r: any) => {
|
||||
const ct = r.cargoTypeId ? cargoById.get(r.cargoTypeId) : null;
|
||||
return [r.reference, {
|
||||
...r,
|
||||
cargoType: ct
|
||||
? { ...ct, wagonTypes: allowed.byCargoTypeId.get(r.cargoTypeId) ?? [] }
|
||||
: null,
|
||||
bookingContainers: lines.filter((l: any) => l.bookingId === r.id).map((l: any) => ({
|
||||
...l,
|
||||
containerType: { id: l.containerTypeId, code: l.code, sizeFt: l.sizeFt },
|
||||
units: [],
|
||||
})),
|
||||
} as unknown as Booking];
|
||||
}));
|
||||
}
|
||||
|
||||
/** The train's real consist, as TRAIN-mode stock. */
|
||||
async function consistStock(codes: Map<string, string>): Promise<WagonStock> {
|
||||
const rows = await ds.query(
|
||||
`SELECT wagon_type_id AS "wagonTypeId", count(*)::int AS n
|
||||
FROM freight.wagons
|
||||
WHERE train_id = $1 AND deleted_at IS NULL
|
||||
GROUP BY wagon_type_id`, [TRAIN_ID]);
|
||||
return {
|
||||
mode: 'TRAIN',
|
||||
remainingByTypeId: new Map(rows.map((r: any) => [r.wagonTypeId, r.n])),
|
||||
codesByTypeId: codes,
|
||||
};
|
||||
}
|
||||
|
||||
const planByType = (plan: any[]) => {
|
||||
const c = new Map<string, number>();
|
||||
for (const s of plan) c.set(s.wagonTypeCode, (c.get(s.wagonTypeCode) ?? 0) + 1);
|
||||
return [...c.entries()].map(([k, n]) => `${n}x ${k}`).join(', ') || 'none';
|
||||
};
|
||||
|
||||
/** Gross tons riding each corridor edge — the locomotive pull check. */
|
||||
const perEdgeGross = (plan: any[], legs: Map<string, { from: number; to: number }>) => {
|
||||
const edges = [0, 0];
|
||||
for (const slot of plan) {
|
||||
const spans = slot.allocations.map((a: any) => legs.get(a.bookingId) ?? { from: 0, to: 2 });
|
||||
const from = Math.min(...spans.map((s: any) => s.from));
|
||||
const to = Math.max(...spans.map((s: any) => s.to));
|
||||
for (let e = from; e < to; e += 1) {
|
||||
edges[e] += Number(slot.tareWeightTons) + Number(slot.assignedWeightTons);
|
||||
}
|
||||
}
|
||||
return edges.map((t) => Math.round(t));
|
||||
};
|
||||
|
||||
async function main() {
|
||||
await ds.initialize();
|
||||
const { allowed, codes } = await loadConfig();
|
||||
const byRef = await loadBookings(allowed);
|
||||
const stock = await consistStock(codes);
|
||||
|
||||
console.log('\n=== S-2026-00045 CONSIST (real) ===');
|
||||
console.table([...stock.remainingByTypeId.entries()].map(([id, n]) => ({
|
||||
wagonType: codes.get(id), wagons: n })));
|
||||
console.log('Route: DCT -> DIRE_DAWA -> GMP (2 edges) Loco pull: 3500T / 1520m\n');
|
||||
|
||||
const ORDERS: Array<{ name: string; refs: string[] }> = [
|
||||
{ name: '1. STAGING ORDER (containers first, bulk last)',
|
||||
refs: ['SF45-5', 'SF45-3', 'SF45-2', 'SF45-1', 'SF45-4'] },
|
||||
{ name: '2. BULK FIRST (bulk books earliest window)',
|
||||
refs: ['SF45-4', 'SF45-1', 'SF45-2', 'SF45-3', 'SF45-5'] },
|
||||
{ name: '3. BULK IN THE MIDDLE',
|
||||
refs: ['SF45-1', 'SF45-2', 'SF45-4', 'SF45-3', 'SF45-5'] },
|
||||
{ name: '4. SEQUENTIAL (as listed by the user)',
|
||||
refs: ['SF45-1', 'SF45-2', 'SF45-3', 'SF45-4', 'SF45-5'] },
|
||||
{ name: '5. BULK ONLY (nothing competing)',
|
||||
refs: ['SF45-4'] },
|
||||
{ name: '6. BULK + the Dire->GMP leg only',
|
||||
refs: ['SF45-5', 'SF45-4'] },
|
||||
];
|
||||
|
||||
const summary: Array<Record<string, unknown>> = [];
|
||||
// Second pass models the staging reality: the bulk booking is PAID (the
|
||||
// customer's money is already taken) while the containers are not.
|
||||
const PAID_BULK = process.env.PAID_BULK === '1';
|
||||
|
||||
for (const order of ORDERS) {
|
||||
const bookings = order.refs.map((r) => {
|
||||
const b = byRef.get(r)!;
|
||||
if (!b) return b;
|
||||
return PAID_BULK && r === 'SF45-4'
|
||||
? ({ ...b, status: 'PAID', paymentStatus: 'PAID' } as Booking)
|
||||
: b;
|
||||
}).filter(Boolean);
|
||||
const legs = new Map(bookings.map((b: any) => [
|
||||
b.id,
|
||||
{ from: STOPS.indexOf(b.originYardId), to: STOPS.indexOf(b.destinationYardId) },
|
||||
]));
|
||||
const result = planWagonsWithStock({
|
||||
bookings, allowed,
|
||||
stock: {
|
||||
...stock,
|
||||
remainingByTypeId: new Map(stock.remainingByTypeId),
|
||||
},
|
||||
legs, edgeCount: 2, stops: STOPS,
|
||||
});
|
||||
|
||||
const refOf = (id: string) =>
|
||||
bookings.find((b: any) => b.id === id)?.reference ?? id;
|
||||
const bulkFitted = result.fitting.some((b) => b.reference === 'SF45-4');
|
||||
const bulkDeferred = result.deferred.find((d) => d.reference === 'SF45-4');
|
||||
const gross = perEdgeGross(result.plan, legs);
|
||||
const excl = validateWagonCargoExclusivity(result.plan, legs, 2);
|
||||
|
||||
console.log(`\n──────── ${order.name} ────────`);
|
||||
console.log(`order: ${order.refs.join(' -> ')}`);
|
||||
console.log(`plan: ${result.plan.length} wagons — ${planByType(result.plan)}`);
|
||||
console.log(`gross per edge: DCT->Dire ${gross[0]}T | Dire->GMP ${gross[1]}T (limit 3500T)`);
|
||||
console.log(`fitting: ${result.fitting.map((b) => b.reference).join(', ') || 'none'}`);
|
||||
for (const d of result.deferred) console.log(`DEFERRED: ${d.reference} — ${d.reason}`);
|
||||
if (excl.length) console.log(`EXCLUSIVITY VIOLATION: ${excl.join('; ')}`);
|
||||
const bulkSlots = result.plan.filter((s) =>
|
||||
s.allocations.some((a) => refOf(a.bookingId) === 'SF45-4'));
|
||||
if (bulkSlots.length) {
|
||||
console.log(` bulk seated on: ${planByType(bulkSlots)}`);
|
||||
}
|
||||
|
||||
summary.push({
|
||||
order: order.name.slice(0, 34),
|
||||
wagons: result.plan.length,
|
||||
'bulk SF45-4': bulkFitted ? 'SELECTED' : 'NOT SELECTED',
|
||||
deferred: result.deferred.map((d) => d.reference).join(',') || '-',
|
||||
reason: bulkDeferred ? bulkDeferred.reason.slice(0, 60) : '-',
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n\n================ SUMMARY ================');
|
||||
console.table(summary);
|
||||
await ds.destroy();
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
96
apps/edr-freight-api/scripts/seed-s45-scenario.cjs
Normal file
96
apps/edr-freight-api/scripts/seed-s45-scenario.cjs
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Seeds the S-2026-00045 reported scenario into edr_dev (idempotent).
|
||||
* References SF45-* ("Scenario Fortyfive"). Rows are kept as evidence.
|
||||
*
|
||||
* SELECT * FROM freight.bookings WHERE reference LIKE 'SF45-%';
|
||||
*/
|
||||
const { Client } = require('pg');
|
||||
|
||||
const YARD = {
|
||||
DCT: 'fc558b95-da28-4fc3-8348-311a290c34ae',
|
||||
DIRE: 'f7b1686f-d43e-42aa-bc6a-d3d5849296c9',
|
||||
GMP: '61ae1e66-c229-4dcd-9851-b2b9424f3a95', // KALITY = GMP (Gelan)
|
||||
};
|
||||
const PERISHABLE = 'a5991d3a-d690-4b7e-98fd-ea3333aa16e7'; // NW5 30T / PW2 20T
|
||||
const FT40 = '349072e7-8a90-4c03-b682-08976abfd7e8';
|
||||
const COMPANY = '300d5510-e3a5-4858-bc28-6e3beda8ca80';
|
||||
|
||||
const BOOKINGS = [
|
||||
{ ref: 'SF45-1', type: 'CONTAINER', from: 'DCT', to: 'DIRE', qty: 9,
|
||||
note: 'S45: DCT->Dire 9x40ft' },
|
||||
{ ref: 'SF45-2', type: 'CONTAINER', from: 'DCT', to: 'DIRE', qty: 19,
|
||||
note: 'S45: DCT->Dire 19x40ft' },
|
||||
{ ref: 'SF45-3', type: 'CONTAINER', from: 'DCT', to: 'DIRE', qty: 19,
|
||||
note: 'S45: DCT->Dire 19x40ft (second)' },
|
||||
{ ref: 'SF45-4', type: 'BULK', from: 'DCT', to: 'GMP', tons: 695,
|
||||
note: 'S45: DCT->GMP 695T Perishable - THE ONE NOT BEING SELECTED' },
|
||||
{ ref: 'SF45-5', type: 'CONTAINER', from: 'DIRE', to: 'GMP', qty: 42,
|
||||
note: 'S45: Dire->GMP 42x40ft' },
|
||||
];
|
||||
|
||||
async function main() {
|
||||
const client = new Client({
|
||||
host: process.env.DB_HOST || '10.18.7.207',
|
||||
port: Number(process.env.DB_PORT || 5432),
|
||||
database: process.env.DB_NAME || 'edr_dev',
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'dcba@1234',
|
||||
});
|
||||
await client.connect();
|
||||
const out = [];
|
||||
|
||||
for (const b of BOOKINGS) {
|
||||
const isBulk = b.type === 'BULK';
|
||||
const vgmPerUnit = 26;
|
||||
const totalVgm = isBulk ? b.tons : b.qty * vgmPerUnit;
|
||||
|
||||
const existing = await client.query(
|
||||
`SELECT id FROM freight.bookings WHERE reference = $1`, [b.ref]);
|
||||
let id;
|
||||
if (existing.rows.length) {
|
||||
id = existing.rows[0].id;
|
||||
await client.query(
|
||||
`UPDATE freight.bookings
|
||||
SET cargo_total_weight_vgm=$2, cargo_type_id=$3, origin_yard_id=$4,
|
||||
destination_yard_id=$5, freight_type=$6, trade_direction='IMPORT',
|
||||
status='CLEARANCE_READY', payment_status='PENDING',
|
||||
scheduling_status='ELIGIBLE', train_schedule_id=NULL,
|
||||
cargo_free_text=$7, updated_at=now()
|
||||
WHERE id=$1`,
|
||||
[id, totalVgm, isBulk ? PERISHABLE : null, YARD[b.from], YARD[b.to],
|
||||
b.type, b.note]);
|
||||
} else {
|
||||
const res = await client.query(
|
||||
`INSERT INTO freight.bookings (
|
||||
reference, status, total_amount, payment_status, contract_type,
|
||||
trade_direction, equipment_return, cargo_total_weight_vgm, is_hazardous,
|
||||
payment_currency, version_number, priority_score, origin_yard_id,
|
||||
destination_yard_id, cargo_type_id, cargo_free_text, freight_type,
|
||||
scheduling_status, is_government, customs_clearing_enabled, is_reefer,
|
||||
booking_type, is_split, company_id, created_at, updated_at
|
||||
) VALUES ($1,'CLEARANCE_READY',0,'PENDING','NEW','IMPORT','WITHOUT_RETURN',
|
||||
$2,false,'ETB',1,0,$3,$4,$5,$6,$7,'ELIGIBLE',false,false,false,
|
||||
'ONE_TIME',false,$8, now(), now())
|
||||
RETURNING id`,
|
||||
[b.ref, totalVgm, YARD[b.from], YARD[b.to],
|
||||
isBulk ? PERISHABLE : null, b.note, b.type, COMPANY]);
|
||||
id = res.rows[0].id;
|
||||
}
|
||||
|
||||
await client.query(`DELETE FROM freight.booking_container WHERE booking_id=$1`, [id]);
|
||||
if (!isBulk) {
|
||||
await client.query(
|
||||
`INSERT INTO freight.booking_container
|
||||
(booking_id, container_type_id, quantity, vgm_per_unit_tons,
|
||||
total_vgm_tons, wagons_required, container_size, created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,'40ft', now(), now())`,
|
||||
[id, FT40, b.qty, vgmPerUnit, b.qty * vgmPerUnit, b.qty]);
|
||||
}
|
||||
out.push({ ref: b.ref, id, note: b.note });
|
||||
}
|
||||
|
||||
console.table(out);
|
||||
await client.end();
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e.message); process.exit(1); });
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user