Merge pull request #1401 from Tria-plc/staging

Staging
This commit is contained in:
marshal
2026-08-23 08:06:44 +03:00
committed by GitHub
164 changed files with 12880 additions and 2886 deletions

BIN
EDR-Freight-User-Guide.pdf Normal file

Binary file not shown.

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,362 @@
/**
* Runs the REAL planner (planWagonsWithStock) against the WGT-* bookings and
* the REAL wagon stock standing in edr_dev. Read-only: it plans, asserts, and
* reports — it writes nothing.
*
* npx ts-node -T scripts/run-wagon-gate-tests.ts
*/
import { DataSource } from 'typeorm';
import { planWagonsWithStock } from '../src/modules/train-scheduling/wagon-plan-flex.util';
import type { AllowedWagonTypeMap, WagonStock } from '../src/modules/train-scheduling/wagon-plan-flex.util';
import { validateWagonCargoExclusivity } from '../src/modules/train-scheduling/utils/wagon-plan.util';
import type { Booking } from '../src/modules/bookings/entities/booking.entity';
import type { WagonType } from '../src/modules/wagon-types/entities/wagon-type.entity';
const YARD = {
DCT: 'fc558b95-da28-4fc3-8348-311a290c34ae',
DIRE: 'f7b1686f-d43e-42aa-bc6a-d3d5849296c9',
KALITY: '61ae1e66-c229-4dcd-9851-b2b9424f3a95',
};
const ds = new DataSource({
type: 'postgres',
host: process.env.DB_HOST || '10.18.7.207',
port: Number(process.env.DB_PORT || 5432),
database: process.env.DB_NAME || 'edr_dev',
username: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'dcba@1234',
});
type Check = { name: string; pass: boolean; detail: string };
const results: Array<{ testCase: string; checks: Check[] }> = [];
const check = (list: Check[], name: string, pass: boolean, detail: string) => {
list.push({ name, pass, detail });
};
/** Wagon types keyed by id, and the allowed-type map read from the join tables. */
async function loadConfig(): Promise<{
allowed: AllowedWagonTypeMap;
byId: Map<string, WagonType>;
codes: Map<string, string>;
}> {
const types: WagonType[] = await ds.query(
`SELECT id, code, name, capacity_tons AS "capacityTons",
length_meters AS "lengthMeters", tare_weight_tons AS "tareWeightTons",
supports_container AS "supportsContainer", is_active AS "isActive"
FROM freight.wagon_types WHERE is_active IS NOT FALSE`,
);
const byId = new Map(types.map((t) => [t.id, t]));
const codes = new Map(types.map((t) => [t.id, t.code]));
const cargoRows: Array<{ typeId: string; wagonTypeId: string }> = await ds.query(
`SELECT cargo_type_id AS "typeId", wagon_type_id AS "wagonTypeId"
FROM freight.cargo_type_wagon_types`,
);
const containerRows: Array<{ typeId: string; wagonTypeId: string }> = await ds.query(
`SELECT container_type_id AS "typeId", wagon_type_id AS "wagonTypeId"
FROM freight.container_type_wagon_types`,
);
const collect = (rows: Array<{ typeId: string; wagonTypeId: string }>) => {
const map = new Map<string, WagonType[]>();
for (const row of rows) {
const wt = byId.get(row.wagonTypeId);
if (!wt) continue;
map.set(row.typeId, [...(map.get(row.typeId) ?? []), wt]);
}
return map;
};
return {
allowed: {
byCargoTypeId: collect(cargoRows),
byContainerTypeId: collect(containerRows),
},
byId,
codes,
};
}
/** The WGT-* bookings, hydrated the way the planner expects them. */
async function loadBookings(refs: string[]): Promise<Booking[]> {
const rows = await ds.query(
`SELECT b.id, b.reference, b.freight_type AS "freightType",
b.cargo_total_weight_vgm AS "cargoTotalWeightVgm",
b.bulk_total_weight_tons AS "bulkTotalWeightTons",
b.cargo_type_id AS "cargoTypeId",
b.origin_yard_id AS "originYardId",
b.destination_yard_id AS "destinationYardId",
b.is_government AS "isGovernment", b.priority_score AS "priorityScore",
b.created_at AS "createdAt"
FROM freight.bookings b
WHERE b.reference = ANY($1) AND b.deleted_at IS NULL`,
[refs],
);
const cargoTypes = await ds.query(
`SELECT ct.id, ct.cargo_type_name AS "cargoTypeName", ct.code,
ct.unit_of_measure AS "unitOfMeasure",
ct.tons_per_wagon_map AS "tonsPerWagonMap",
ct.items_per_wagon_map AS "itemsPerWagonMap"
FROM freight.cargo_types ct`,
);
const cargoById = new Map(cargoTypes.map((c: any) => [c.id, c]));
const { allowed } = await loadConfig();
const lines = await ds.query(
`SELECT bc.id, bc.booking_id AS "bookingId", bc.container_type_id AS "containerTypeId",
bc.quantity, bc.vgm_per_unit_tons AS "vgmPerUnitTons",
bc.wagons_required AS "wagonsRequired", bc.container_number AS "containerNumber",
ct.code, ct.size_ft AS "sizeFt"
FROM freight.booking_container bc
JOIN freight.container_types ct ON ct.id = bc.container_type_id
WHERE bc.booking_id = ANY($1) AND bc.deleted_at IS NULL`,
[rows.map((r: any) => r.id)],
);
return rows.map((r: any) => {
const cargoType = r.cargoTypeId ? cargoById.get(r.cargoTypeId) : null;
return {
...r,
cargoType: cargoType
? { ...cargoType, wagonTypes: allowed.byCargoTypeId.get(r.cargoTypeId) ?? [] }
: null,
bookingContainers: lines
.filter((l: any) => l.bookingId === r.id)
.map((l: any) => ({
...l,
containerType: { id: l.containerTypeId, code: l.code, sizeFt: l.sizeFt },
units: [],
})),
} as unknown as Booking;
});
}
/** Real AVAILABLE wagons standing at a yard, by type. */
async function stockAtYard(yardId: string, codes: Map<string, string>): Promise<WagonStock> {
const rows = await ds.query(
`SELECT wagon_type_id AS "wagonTypeId", count(*)::int AS n
FROM freight.wagons
WHERE current_yard_id = $1 AND deleted_at IS NULL
AND status = 'AVAILABLE' AND train_id IS NULL
AND current_train_schedule_id IS NULL
GROUP BY wagon_type_id`,
[yardId],
);
return {
mode: 'YARD',
remainingByTypeId: new Map(rows.map((r: any) => [r.wagonTypeId, r.n])),
codesByTypeId: codes,
};
}
/** Per-type slot counts of a plan, as a readable "10x PW2, 17x NW5". */
const planByType = (plan: any[]): string => {
const counts = new Map<string, number>();
for (const slot of plan) {
counts.set(slot.wagonTypeCode, (counts.get(slot.wagonTypeCode) ?? 0) + 1);
}
return [...counts.entries()].map(([code, n]) => `${n}x ${code}`).join(', ') || 'none';
};
async function main() {
await ds.initialize();
const { allowed, codes } = await loadConfig();
const dctStock = await stockAtYard(YARD.DCT, codes);
console.log('\n=== REAL FLEET AT DORALEH_FREEZONE (AVAILABLE, unpinned) ===');
console.table(
[...dctStock.remainingByTypeId.entries()].map(([id, n]) => ({
wagonType: codes.get(id),
available: n,
})),
);
// ---------------------------------------------------------------- CASE A
{
const checks: Check[] = [];
const bookings = await loadBookings(['WGT-A1', 'WGT-A2', 'WGT-A3']);
// Constrain the fleet to make the contest real: 20 NW5 + 10 PW2.
const stock: WagonStock = {
mode: 'TRAIN',
remainingByTypeId: new Map([
['8f717b09-eec1-46ad-be3d-2dc0a56e55e7', 20], // NW5
['8eec3a7d-8482-4397-96b6-59a028210722', 10], // PW2
]),
codesByTypeId: codes,
};
const result = planWagonsWithStock({ bookings, allowed, stock });
const bulkSlots = result.plan.filter((s) => s.slotLoadType === 'BULK');
const pw2Bulk = bulkSlots.filter((s) => s.wagonTypeCode === 'PW2').length;
const nw5Bulk = bulkSlots.filter((s) => s.wagonTypeCode === 'NW5').length;
check(checks, 'bulk fills bulk-only PW2 first', pw2Bulk === 10,
`${pw2Bulk}/10 PW2 used by bulk`);
check(checks, 'PW2 bulk wagons respect the 20T cargo cap',
bulkSlots.filter((s) => s.wagonTypeCode === 'PW2')
.every((s) => s.assignedWeightTons <= 20),
`max PW2 load ${Math.max(0, ...bulkSlots.filter((s) => s.wagonTypeCode === 'PW2').map((s) => s.assignedWeightTons))}T (cap 20T)`);
check(checks, 'NW5 bulk wagons respect the 30T cargo cap',
bulkSlots.filter((s) => s.wagonTypeCode === 'NW5')
.every((s) => s.assignedWeightTons <= 30),
`max NW5 bulk load ${Math.max(0, ...bulkSlots.filter((s) => s.wagonTypeCode === 'NW5').map((s) => s.assignedWeightTons))}T (cap 30T)`);
check(checks, 'no wagon mixes bulk with other cargo',
validateWagonCargoExclusivity(result.plan).length === 0,
validateWagonCargoExclusivity(result.plan).join('; ') || 'clean');
check(checks, 'plan is honest about who fits',
result.fitting.length + result.deferred.length === 3,
`fitting=[${result.fitting.map((b) => b.reference)}] deferred=[${result.deferred.map((d) => d.reference)}]`);
results.push({ testCase: 'A — bulk vs container contest for NW5 (20 NW5 + 10 PW2)', checks });
console.log('\n=== CASE A: 695T Perishable + 12x40ft + 16x20ft, stock 20 NW5 / 10 PW2 ===');
console.log(`plan: ${result.plan.length} wagons — ${planByType(result.plan)}`);
console.log(` bulk: ${pw2Bulk}x PW2 + ${nw5Bulk}x NW5`);
console.log(`fitting: ${result.fitting.map((b) => b.reference).join(', ') || 'none'}`);
for (const d of result.deferred) console.log(`deferred: ${d.reference}${d.reason}`);
}
// ---------------------------------------------------------------- CASE B
{
const checks: Check[] = [];
const bookings = await loadBookings(['WGT-B1', 'WGT-B2']);
const stops = [YARD.DCT, YARD.DIRE, YARD.KALITY];
const legs = new Map(
bookings.map((b: any) => [
b.id,
{ from: stops.indexOf(b.originYardId), to: stops.indexOf(b.destinationYardId) },
]),
);
const stock: WagonStock = {
mode: 'TRAIN',
remainingByTypeId: new Map([
['8f717b09-eec1-46ad-be3d-2dc0a56e55e7', 3], // NW5 — only 3, forces reuse
['8eec3a7d-8482-4397-96b6-59a028210722', 0],
]),
codesByTypeId: codes,
};
const result = planWagonsWithStock({
bookings, allowed, stock, legs, edgeCount: 2, stops,
});
check(checks, 'both disjoint-leg bookings fit on 3 wagons',
result.fitting.length === 2 && result.plan.length <= 3,
`fitting=${result.fitting.length}/2, wagons=${result.plan.length}`);
const legIssues = validateWagonCargoExclusivity(result.plan, legs, 2);
check(checks, 'no wagon carries bulk + container on the SAME leg',
legIssues.length === 0, legIssues.join('; ') || 'clean');
// …and the leg-blind reading WOULD flag it, proving the reuse is real.
check(checks, 'the same wagon does carry both kinds across DIFFERENT legs',
validateWagonCargoExclusivity(result.plan).length > 0,
'leg-blind check sees bulk+container on one wagon (legal: disjoint legs)');
const reused = result.plan.filter((s) => s.allocations.length > 1);
check(checks, 'leg-disjoint reuse actually happens',
reused.length > 0,
`${reused.length} wagon(s) carry both bookings on different legs`);
results.push({ testCase: 'B — leg-disjoint reuse (container leg 1, bulk leg 2), only 3 NW5', checks });
console.log('\n=== CASE B: 6x20ft DCT->Dire + 120T bulk Dire->Kality, only 3 NW5 ===');
console.log(`plan: ${result.plan.length} wagons — ${planByType(result.plan)}`);
for (const slot of result.plan) {
console.log(` wagon #${slot.sequenceNo} (${slot.wagonTypeCode}): ${slot.allocations
.map((a) => `${a.bookingReference}/${a.loadType} ${a.allocatedWeightTons}T`).join(' + ')}`);
}
for (const d of result.deferred) console.log(`deferred: ${d.reference}${d.reason}`);
}
// ---------------------------------------------------------------- CASE C
{
const checks: Check[] = [];
const bookings = await loadBookings(['WGT-C1']);
const stock: WagonStock = {
mode: 'TRAIN',
remainingByTypeId: new Map([
['8f717b09-eec1-46ad-be3d-2dc0a56e55e7', 20], // NW5 (40T cap for steel)
['8eec3a7d-8482-4397-96b6-59a028210722', 20], // PW2 (10T cap for steel!)
]),
codesByTypeId: codes,
};
const result = planWagonsWithStock({ bookings, allowed, stock });
const nw5 = result.plan.filter((s) => s.wagonTypeCode === 'NW5').length;
const pw2 = result.plan.filter((s) => s.wagonTypeCode === 'PW2').length;
check(checks, 'inverted caps: uses the 40T NW5, not the 10T PW2',
nw5 === 10 && pw2 === 0,
`${nw5}x NW5 (40T cap) + ${pw2}x PW2 (10T cap) for 400T`);
check(checks, 'wagon count matches the cap math (400/40 = 10)',
result.plan.length === 10, `${result.plan.length} wagons`);
results.push({ testCase: 'C — cap inversion (Steel Billet: NW5 40T vs PW2 10T)', checks });
console.log('\n=== CASE C: 400T Steel Billet, caps NW5=40T PW2=10T, both in stock ===');
console.log(`plan: ${result.plan.length} wagons — ${planByType(result.plan)}`);
}
// ---------------------------------------------------------------- CASE D
{
const checks: Check[] = [];
const bookings = await loadBookings(['WGT-D1', 'WGT-D2']);
const stock = await stockAtYard(YARD.DCT, codes);
const result = planWagonsWithStock({ bookings, allowed, stock });
const d1 = result.plan.filter((s) =>
s.allocations.some((a) => a.bookingReference === 'WGT-D1'));
const d2 = result.plan.filter((s) =>
s.allocations.some((a) => a.bookingReference === 'WGT-D2'));
check(checks, 'Beans (PW2-only) is deferred — no PW2 free at DCT',
result.deferred.some((d) => d.reference === 'WGT-D1'),
result.deferred.find((d) => d.reference === 'WGT-D1')?.reason ?? `planned on ${planByType(d1)}`);
check(checks, 'Sand rides only its configured CW3/CW4',
d2.length > 0 && d2.every((s) => ['CW3', 'CW4'].includes(s.wagonTypeCode)),
`Sand on ${planByType(d2)}`);
results.push({ testCase: 'D — exclusive-type cargo against REAL DCT stock', checks });
console.log('\n=== CASE D: 200T Beans (PW2-only) + 300T Sand (CW3/CW4-only), REAL stock ===');
console.log(`plan: ${result.plan.length} wagons — ${planByType(result.plan)}`);
for (const d of result.deferred) console.log(`deferred: ${d.reference}${d.reason}`);
}
// ---------------------------------------------------------------- CASE E
{
const checks: Check[] = [];
const bookings = await loadBookings(['WGT-E1', 'WGT-E2']);
const stock: WagonStock = {
mode: 'TRAIN',
remainingByTypeId: new Map([
['8f717b09-eec1-46ad-be3d-2dc0a56e55e7', 30],
['8eec3a7d-8482-4397-96b6-59a028210722', 5],
]),
codesByTypeId: codes,
};
const result = planWagonsWithStock({ bookings, allowed, stock });
check(checks, '3000T booking is deferred, not silently truncated',
result.deferred.some((d) => d.reference === 'WGT-E1'),
result.deferred.find((d) => d.reference === 'WGT-E1')?.reason ?? 'FITTED (unexpected)');
const e2 = result.plan.filter((s) =>
s.allocations.some((a) => a.bookingReference === 'WGT-E2'));
check(checks, 'the 45T booking never shares a wagon with another booking',
e2.every((s) => new Set(s.allocations.map((a) => a.bookingId)).size === 1),
`${e2.length} wagon(s), all single-booking`);
check(checks, 'exclusivity holds across the whole plan',
validateWagonCargoExclusivity(result.plan).length === 0,
validateWagonCargoExclusivity(result.plan).join('; ') || 'clean');
results.push({ testCase: 'E — overload one leg (3000T) + small bulk beside it', checks });
console.log('\n=== CASE E: 3000T + 45T Perishable, stock 30 NW5 / 5 PW2 ===');
console.log(`plan: ${result.plan.length} wagons — ${planByType(result.plan)}`);
console.log(`fitting: ${result.fitting.map((b) => b.reference).join(', ') || 'none'}`);
for (const d of result.deferred) console.log(`deferred: ${d.reference}${d.reason}`);
}
// ---------------------------------------------------------------- REPORT
console.log('\n\n================ TEST REPORT ================');
let passed = 0;
let failed = 0;
for (const group of results) {
console.log(`\n${group.testCase}`);
for (const c of group.checks) {
console.log(` ${c.pass ? 'PASS' : 'FAIL'} ${c.name}\n ${c.detail}`);
c.pass ? (passed += 1) : (failed += 1);
}
}
console.log(`\n---------------------------------------------`);
console.log(`TOTAL: ${passed} passed, ${failed} failed`);
await ds.destroy();
process.exit(failed > 0 ? 1 : 0);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,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

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

View File

@@ -75,7 +75,7 @@ export const TrainSchedulingView = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.view); BookingStaff(FREIGHT_PERMS.trainScheduling.view);
// Granular train-scheduling actions replace the retired coarse manage: // Granular train-scheduling actions replace the retired coarse manage:
// create a schedule, update (assign/consist/loading/finalize/dispatch/arrive…), // create a schedule, update (assign/consist/finalize/dispatch/arrive…),
// cancel a schedule, reschedule (+ maintenance), and manage global rules. // cancel a schedule, reschedule (+ maintenance), and manage global rules.
export const TrainSchedulingCreate = () => export const TrainSchedulingCreate = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.create); BookingStaff(FREIGHT_PERMS.trainScheduling.create);
@@ -83,6 +83,18 @@ export const TrainSchedulingCreate = () =>
export const TrainSchedulingUpdate = () => export const TrainSchedulingUpdate = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.update); BookingStaff(FREIGHT_PERMS.trainScheduling.update);
/**
* Confirm a booking's cargo loaded/unloaded at a yard — carved out of the
* coarse `update` so it can be granted independently of general schedule
* editing. Same two keys gate import, export, and intercity movements alike:
* the generic per-booking route and the intercity-specific one both use them.
*/
export const TrainSchedulingLoad = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.load);
export const TrainSchedulingUnload = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.unload);
export const TrainSchedulingCancel = () => export const TrainSchedulingCancel = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.cancel); BookingStaff(FREIGHT_PERMS.trainScheduling.cancel);

View File

@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/** Optional payment due date finance can set on an additional charge. */
export class AdditionalChargeDueAt3650000000000 implements MigrationInterface {
name = 'AdditionalChargeDueAt3650000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "freight"."additional_charge"
ADD COLUMN IF NOT EXISTS "due_at" timestamptz
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "freight"."additional_charge" DROP COLUMN IF EXISTS "due_at"
`);
}
}

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-schedule wagon CUT plan — the mid-route stop where THIS departure
* detaches each consist wagon and leaves it behind (10 wagons cut at Mojo,
* the rest ride to Djibouti).
*
* Sparse jsonb map `{ wagonId: yardId }` on the schedule: a wagon missing
* from the map rides to the schedule destination — exactly today's behavior,
* so no backfill. The cut is a cap, not a promise: cargo may still alight
* earlier, but never past the cut. Booking capacity debits every edge at or
* after the cut; checkpoint logging settles the wagon there physically.
*/
export class SchedulePlannedWagonCutYards3650000000000 implements MigrationInterface {
name = 'SchedulePlannedWagonCutYards3650000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS planned_wagon_cut_yards jsonb
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS planned_wagon_cut_yards
`);
}
}

View File

@@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-schedule consist-change plan, executed automatically as the trip
* proceeds (dispatch / checkpoint logs):
*
* - `planned_wagon_couples` `{ wagonId: pickupYardId }` — LOOSE wagons this
* departure couples onto the train at a route stop. They join the built
* train permanently when the train reaches that stop.
* - `planned_wagon_real_cuts` `[wagonId, ...]` — cut wagons (see
* planned_wagon_cut_yards) flagged as REAL cuts: the built train
* permanently loses the wagon at its cut yard, instead of the default
* soft cut where it stays in the build and only sits out this trip.
*/
export class SchedulePlannedWagonCouples3660000000000 implements MigrationInterface {
name = 'SchedulePlannedWagonCouples3660000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS planned_wagon_couples jsonb
`);
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS planned_wagon_real_cuts jsonb
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS planned_wagon_couples
`);
await queryRunner.query(`
ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS planned_wagon_real_cuts
`);
}
}

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* A consist adjustment made from the TRAIN BUILDER on a train with no live
* schedule still belongs in the wagon adjustment history — it just has no
* schedule to point at. Relax the NOT NULL so builder detaches/attaches can
* be recorded; every existing reader filters BY train_schedule_id or
* train_id, so nullable rows are invisible to them.
*/
export class AdjustmentLogNullableSchedule3670000000000 implements MigrationInterface {
name = 'AdjustmentLogNullableSchedule3670000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.schedule_wagon_adjustment_logs
ALTER COLUMN train_schedule_id DROP NOT NULL
`);
}
public async down(_queryRunner: QueryRunner): Promise<void> {
// No-op: restoring NOT NULL would fail on any builder-origin rows written
// while this migration was live, re-introducing the outage it fixed.
}
}

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Every slot → allocations lookup (allocator, journey load/unload, settle,
* per-leg weight guard) filters wagon_booking_allocations by
* train_set_wagon_id, which had no index — only booking_id and the pkey.
* Sequential scans grow with every allocation ever written.
*/
export class WagonAllocationSlotIndex3680000000000 implements MigrationInterface {
name = 'WagonAllocationSlotIndex3680000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wagon_booking_allocations_slot
ON freight.wagon_booking_allocations (train_set_wagon_id)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP INDEX IF EXISTS freight.idx_wagon_booking_allocations_slot
`);
}
}

View File

@@ -1,5 +1,6 @@
import { Freight } from "@edr/types"; import { Freight } from "@edr/types";
import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "../bookings/entities/booking-wagon-cancellation.entity";
import { BillingService } from "./billing.service"; import { BillingService } from "./billing.service";
/** /**
@@ -1033,3 +1034,73 @@ describe("BillingService.document", () => {
expect(render).not.toHaveBeenCalled(); 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 { Booking } from "../bookings/entities/booking.entity";
import { AdditionalCharge } from "../bookings/entities/additional-charge.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 // Entity-only import (no module edge): portal reads resolve shipping-line
// payers straight off the table. // payers straight off the table.
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity"; import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
@@ -31,6 +32,7 @@ import {
InvoiceDocumentService, InvoiceDocumentService,
pngDataUrl, pngDataUrl,
} from "./documents/invoice-document.service"; } from "./documents/invoice-document.service";
import { INVOICE_SORT_COLUMNS } from "./dto/filter-invoice.dto";
import { InvoiceLine } from "./entities/invoice-line.entity"; import { InvoiceLine } from "./entities/invoice-line.entity";
import { Invoice, InvoicePayment } from "./entities/invoice.entity"; import { Invoice, InvoicePayment } from "./entities/invoice.entity";
import { InvoiceLineRepository } from "./invoice-line.repository"; import { InvoiceLineRepository } from "./invoice-line.repository";
@@ -98,6 +100,31 @@ export interface RecordPaymentInput {
} }
/** Default invoice payment-term window, in days, used to compute `dueAt`. */ /** Default invoice payment-term window, in days, used to compute `dueAt`. */
/**
* Every dimension the backoffice invoice list narrows by. `findAllPaginated`
* and `collectedSummary` share it so the summary card can never total a
* different set of invoices than the table below it shows.
*/
export interface InvoiceListFilters {
companyId?: string;
status?: Freight.InvoiceStatus;
statuses?: Freight.InvoiceStatus[];
sources?: string[];
eimsStatuses?: string[];
currency?: string;
search?: string;
issuedFrom?: string;
issuedTo?: string;
dueFrom?: string;
dueTo?: string;
minAmount?: number;
maxAmount?: number;
hasBalance?: boolean;
overdue?: boolean;
/** Per-user trade-direction scope, applied via the source booking. */
tradeDirections?: string[];
}
const DEFAULT_DUE_DAYS = 14; const DEFAULT_DUE_DAYS = 14;
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */ /** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
@@ -244,12 +271,7 @@ export class BillingService {
/** Same list filters `findAllPaginated` and `collectedSummary` both narrow by. */ /** Same list filters `findAllPaginated` and `collectedSummary` both narrow by. */
private applyInvoiceFilters( private applyInvoiceFilters(
qb: SelectQueryBuilder<Invoice>, qb: SelectQueryBuilder<Invoice>,
filter: { filter: InvoiceListFilters,
companyId?: string;
status?: Freight.InvoiceStatus;
search?: string;
tradeDirections?: string[];
},
) { ) {
if (filter.companyId) { if (filter.companyId) {
qb.andWhere("invoice.companyId = :companyId", { qb.andWhere("invoice.companyId = :companyId", {
@@ -259,6 +281,57 @@ export class BillingService {
if (filter.status) { if (filter.status) {
qb.andWhere("invoice.status = :status", { status: filter.status }); qb.andWhere("invoice.status = :status", { status: filter.status });
} }
if (filter.statuses?.length) {
qb.andWhere("invoice.status IN (:...statuses)", {
statuses: filter.statuses,
});
}
if (filter.sources?.length) {
qb.andWhere("invoice.source IN (:...sources)", { sources: filter.sources });
}
if (filter.eimsStatuses?.length) {
qb.andWhere("invoice.eimsStatus IN (:...eimsStatuses)", {
eimsStatuses: filter.eimsStatuses,
});
}
if (filter.currency) {
// Stored casing has drifted ("usd" rows exist) — compare normalised.
qb.andWhere("UPPER(invoice.currency) = :currency", {
currency: filter.currency.toUpperCase(),
});
}
if (filter.issuedFrom) {
qb.andWhere("invoice.issuedAt >= :issuedFrom", {
issuedFrom: filter.issuedFrom,
});
}
if (filter.issuedTo) {
qb.andWhere("invoice.issuedAt <= :issuedTo", { issuedTo: filter.issuedTo });
}
if (filter.dueFrom) {
qb.andWhere("invoice.dueAt >= :dueFrom", { dueFrom: filter.dueFrom });
}
if (filter.dueTo) {
qb.andWhere("invoice.dueAt <= :dueTo", { dueTo: filter.dueTo });
}
if (filter.minAmount !== undefined) {
qb.andWhere("invoice.totalAmount >= :minAmount", {
minAmount: filter.minAmount,
});
}
if (filter.maxAmount !== undefined) {
qb.andWhere("invoice.totalAmount <= :maxAmount", {
maxAmount: filter.maxAmount,
});
}
if (filter.hasBalance) {
qb.andWhere("invoice.balanceAmount > 0");
}
if (filter.overdue) {
// Computed, not `status = OVERDUE`: nothing sweeps PENDING rows into
// that status, so reading the column alone under-reports the arrears.
qb.andWhere("invoice.balanceAmount > 0 AND invoice.dueAt < now()");
}
if (filter.search) { if (filter.search) {
// Searches what the row actually shows: its number, who it bills, and // Searches what the row actually shows: its number, who it bills, and
// the source record behind it (booking reference, GRN, shipping line). // the source record behind it (booking reference, GRN, shipping line).
@@ -300,14 +373,11 @@ export class BillingService {
} }
async findAllPaginated( async findAllPaginated(
filter: { filter: InvoiceListFilters & {
companyId?: string;
status?: Freight.InvoiceStatus;
search?: string;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
/** Per-user trade-direction scope, applied via the source booking. */ sortBy?: string;
tradeDirections?: string[]; sortOrder?: "ASC" | "DESC";
} = {}, } = {},
): Promise<{ items: InvoiceListRow[]; total: number }> { ): Promise<{ items: InvoiceListRow[]; total: number }> {
const page = filter.page && filter.page > 0 ? filter.page : 1; const page = filter.page && filter.page > 0 ? filter.page : 1;
@@ -318,7 +388,14 @@ export class BillingService {
.getRepository(Invoice) .getRepository(Invoice)
.createQueryBuilder("invoice") .createQueryBuilder("invoice")
.leftJoinAndSelect("invoice.company", "company") .leftJoinAndSelect("invoice.company", "company")
.orderBy("invoice.issuedAt", "DESC") // sortBy is whitelisted through INVOICE_SORT_COLUMNS, never interpolated
// raw. The id tiebreaker keeps paging stable when the sort column ties
// (issuedAt is null on every DRAFT row).
.orderBy(
INVOICE_SORT_COLUMNS[filter.sortBy ?? ""] ?? "invoice.issuedAt",
filter.sortOrder ?? "DESC",
)
.addOrderBy("invoice.id", "ASC")
.skip((page - 1) * pageSize) .skip((page - 1) * pageSize)
.take(pageSize); .take(pageSize);
@@ -459,12 +536,7 @@ export class BillingService {
* visible page. * visible page.
*/ */
async collectedSummary( async collectedSummary(
filter: { filter: InvoiceListFilters = {},
companyId?: string;
status?: Freight.InvoiceStatus;
search?: string;
tradeDirections?: string[];
} = {},
): Promise<Record<string, number>> { ): Promise<Record<string, number>> {
const qb = this.dataSource const qb = this.dataSource
.getRepository(Invoice) .getRepository(Invoice)
@@ -639,7 +711,14 @@ export class BillingService {
throw new BadRequestException("The bank payment slip file is required."); 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({ const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: invoice.sourceId }, where: { id: invoice.sourceId },
select: ["id", "paymentDeadline"], select: ["id", "paymentDeadline"],

View File

@@ -0,0 +1,53 @@
import { plainToInstance } from "class-transformer";
import { validateSync } from "class-validator";
import { FilterInvoiceDto } from "./filter-invoice.dto";
/**
* The list endpoint runs under `forbidNonWhitelisted`, so every param the
* backoffice filter bar sends has to survive transform + validation here or
* the whole request 400s. The CSV filters are the fragile part: they arrive as
* one string and must come out as a validated array.
*/
const parse = (query: Record<string, string>) => {
const dto = plainToInstance(FilterInvoiceDto, query);
return { dto, errors: validateSync(dto).map((e) => e.property) };
};
describe("FilterInvoiceDto", () => {
it("accepts the full filter-bar query and splits the CSV filters", () => {
const { dto, errors } = parse({
page: "2",
pageSize: "10",
search: "INV-2026",
statuses: "PENDING,OVERDUE",
sources: "booking,warehouse",
eimsStatuses: "NOT_SUBMITTED",
currency: "etb",
issuedFrom: "2026-08-01T00:00:00.000Z",
issuedTo: "2026-08-20T20:59:59.999Z",
dueFrom: "2026-08-01T00:00:00.000Z",
dueTo: "2026-09-01T20:59:59.999Z",
minAmount: "100",
maxAmount: "5000",
hasBalance: "true",
overdue: "false",
sortBy: "balanceAmount",
sortOrder: "asc",
});
expect(errors).toEqual([]);
expect(dto.statuses).toEqual(["PENDING", "OVERDUE"]);
expect(dto.sources).toEqual(["booking", "warehouse"]);
expect(dto.currency).toBe("ETB");
expect(dto.minAmount).toBe(100);
expect(dto.hasBalance).toBe(true);
expect(dto.overdue).toBe(false);
expect(dto.sortOrder).toBe("ASC");
});
it("rejects a value outside the enum and an unsortable column", () => {
expect(parse({ statuses: "PENDING,NOT_A_STATUS" }).errors).toEqual(["statuses"]);
expect(parse({ sortBy: "eimsIrn" }).errors).toEqual(["sortBy"]);
});
});

View File

@@ -2,14 +2,43 @@ import { Freight } from "@edr/types";
import { ApiPropertyOptional } from "@nestjs/swagger"; import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer"; import { Transform } from "class-transformer";
import { import {
IsArray,
IsBoolean,
IsDateString,
IsIn, IsIn,
IsInt, IsInt,
IsNumber,
IsOptional, IsOptional,
IsString, IsString,
IsUUID, IsUUID,
Min, Min,
} from "class-validator"; } from "class-validator";
import { EimsInvoiceStatus } from "../../eims/eims-registration.types";
/** Columns the invoice list may be ordered by -> their query-builder expression. */
export const INVOICE_SORT_COLUMNS: Record<string, string> = {
issuedAt: "invoice.issuedAt",
dueAt: "invoice.dueAt",
createdAt: "invoice.createdAt",
totalAmount: "invoice.totalAmount",
balanceAmount: "invoice.balanceAmount",
invoiceNumber: "invoice.invoiceNumber",
};
/** `?statuses=A,B` -> `["A","B"]`. A bare value stays a one-element list. */
const csv = ({ value }: { value: unknown }) =>
typeof value === "string"
? value
.split(",")
.map((v) => v.trim())
.filter(Boolean)
: value;
const bool = ({ value }: { value: unknown }) => value === "true" || value === true;
const num = ({ value }: { value: unknown }) => Number(value);
export class FilterInvoiceDto { export class FilterInvoiceDto {
@ApiPropertyOptional({ default: 1 }) @ApiPropertyOptional({ default: 1 })
@IsOptional() @IsOptional()
@@ -40,10 +69,97 @@ export class FilterInvoiceDto {
@IsIn(Object.values(Freight.InvoiceStatus)) @IsIn(Object.values(Freight.InvoiceStatus))
status?: Freight.InvoiceStatus; status?: Freight.InvoiceStatus;
/** Manual-payments worklist only: restrict to one currency. */ /**
* Multi-select status (`?statuses=PENDING,OVERDUE`). ANDed with `status`
* when both are sent, so the single-status worklists keep their meaning.
*/
@ApiPropertyOptional({ isArray: true, enum: Freight.InvoiceStatus })
@IsOptional()
@Transform(csv)
@IsArray()
@IsIn(Object.values(Freight.InvoiceStatus), { each: true })
statuses?: Freight.InvoiceStatus[];
/** Originating subsystem (`booking`, `warehouse`, `shipping_line_credit`, …). */
@ApiPropertyOptional({ isArray: true, enum: Freight.InvoiceSource })
@IsOptional()
@Transform(csv)
@IsArray()
@IsIn(Object.values(Freight.InvoiceSource), { each: true })
sources?: Freight.InvoiceSource[];
/** MoR filing state — Finance's "what still needs registering" cut. */
@ApiPropertyOptional({ isArray: true, enum: EimsInvoiceStatus })
@IsOptional()
@Transform(csv)
@IsArray()
@IsIn(Object.values(EimsInvoiceStatus), { each: true })
eimsStatuses?: EimsInvoiceStatus[];
/** Manual-payments worklist and the invoice list: restrict to one currency. */
@ApiPropertyOptional({ enum: ["USD", "ETB"] }) @ApiPropertyOptional({ enum: ["USD", "ETB"] })
@IsOptional() @IsOptional()
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase()) @Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
@IsIn(["USD", "ETB"]) @IsIn(["USD", "ETB"])
currency?: "USD" | "ETB"; currency?: "USD" | "ETB";
@ApiPropertyOptional({ description: "Issued at or after this instant (ISO)." })
@IsOptional()
@IsDateString()
issuedFrom?: string;
@ApiPropertyOptional({ description: "Issued at or before this instant (ISO)." })
@IsOptional()
@IsDateString()
issuedTo?: string;
@ApiPropertyOptional({ description: "Due at or after this instant (ISO)." })
@IsOptional()
@IsDateString()
dueFrom?: string;
@ApiPropertyOptional({ description: "Due at or before this instant (ISO)." })
@IsOptional()
@IsDateString()
dueTo?: string;
/** Total amount bounds, in the invoice's own currency — pair with `currency`. */
@ApiPropertyOptional()
@IsOptional()
@Transform(num)
@IsNumber()
minAmount?: number;
@ApiPropertyOptional()
@IsOptional()
@Transform(num)
@IsNumber()
maxAmount?: number;
@ApiPropertyOptional({ description: "Only invoices with an outstanding balance." })
@IsOptional()
@Transform(bool)
@IsBoolean()
hasBalance?: boolean;
/**
* Outstanding AND past its due date, computed rather than read off `status`:
* nothing sweeps PENDING rows into OVERDUE, so the status alone under-reports.
*/
@ApiPropertyOptional({ description: "Only invoices outstanding past their due date." })
@IsOptional()
@Transform(bool)
@IsBoolean()
overdue?: boolean;
@ApiPropertyOptional({ enum: Object.keys(INVOICE_SORT_COLUMNS), default: "issuedAt" })
@IsOptional()
@IsIn(Object.keys(INVOICE_SORT_COLUMNS))
sortBy?: string;
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "DESC" })
@IsOptional()
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
@IsIn(["ASC", "DESC"])
sortOrder?: "ASC" | "DESC";
} }

View File

@@ -1,6 +1,7 @@
import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
import { DataSource, EntityManager } from 'typeorm'; import { DataSource, EntityManager } from 'typeorm';
import { ExchangeService } from '@edr/api-common';
import { Freight, NotificationAudience, NotificationType } from '@edr/types'; import { Freight, NotificationAudience, NotificationType } from '@edr/types';
import { BillingService, InvoiceEventPayload } from '../billing/billing.service'; import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
@@ -35,6 +36,7 @@ export class AdditionalChargeService {
private readonly repository: AdditionalChargeRepository, private readonly repository: AdditionalChargeRepository,
private readonly bookingsRepository: BookingsRepository, private readonly bookingsRepository: BookingsRepository,
private readonly filesService: FilesService, private readonly filesService: FilesService,
private readonly exchangeService: ExchangeService,
private readonly billing: BillingService, private readonly billing: BillingService,
private readonly bookingsService: BookingsService, private readonly bookingsService: BookingsService,
private readonly notifications: NotificationsService, private readonly notifications: NotificationsService,
@@ -74,6 +76,7 @@ export class AdditionalChargeService {
reason: dto.reason.trim(), reason: dto.reason.trim(),
amount: dto.amount.toFixed(2), amount: dto.amount.toFixed(2),
currency: dto.currency.trim().toUpperCase(), currency: dto.currency.trim().toUpperCase(),
dueAt: dto.dueDate ? new Date(dto.dueDate) : null,
status: 'DRAFT', status: 'DRAFT',
createdByStaffId: staffId, createdByStaffId: staffId,
}), }),
@@ -132,6 +135,8 @@ export class AdditionalChargeService {
companyId: booking.companyId, companyId: booking.companyId,
companyProfileId: booking.companyProfileId, companyProfileId: booking.companyProfileId,
currency: charge.currency, currency: charge.currency,
// Unset falls through to BillingService's own DEFAULT_DUE_DAYS (14).
dueAt: charge.dueAt ?? undefined,
lines: [ lines: [
{ {
chargeType: 'ADDITIONAL_CHARGE', chargeType: 'ADDITIONAL_CHARGE',
@@ -254,9 +259,12 @@ export class AdditionalChargeService {
? await this.dataSource.getRepository(Invoice).find({ where: invoiceIds.map((id) => ({ id })) }) ? await this.dataSource.getRepository(Invoice).find({ where: invoiceIds.map((id) => ({ id })) })
: []; : [];
const invoiceById = new Map(invoices.map((i) => [i.id, i])); const invoiceById = new Map(invoices.map((i) => [i.id, i]));
const converted = await Promise.all(rows.map((r) => this.convertAmount(r)));
const convertedById = new Map(rows.map((r, i) => [r.id, converted[i]]));
return rows.map((r) => { return rows.map((r) => {
const file = filesByCharge.get(r.id)?.[0]; const file = filesByCharge.get(r.id)?.[0];
const fx = convertedById.get(r.id) ?? null;
return { return {
id: r.id, id: r.id,
bookingId: r.bookingId, bookingId: r.bookingId,
@@ -264,6 +272,9 @@ export class AdditionalChargeService {
status: r.status, status: r.status,
amount: Number(r.amount), amount: Number(r.amount),
currency: r.currency, currency: r.currency,
convertedAmount: fx?.amount ?? null,
convertedCurrency: fx?.currency ?? null,
dueAt: r.dueAt?.toISOString() ?? null,
file: file ? { id: file.id, name: file.name, url: file.url } : null, file: file ? { id: file.id, name: file.name, url: file.url } : null,
invoiceId: r.invoiceId ?? null, invoiceId: r.invoiceId ?? null,
invoiceNumber: r.invoiceId ? (invoiceById.get(r.invoiceId)?.invoiceNumber ?? null) : null, invoiceNumber: r.invoiceId ? (invoiceById.get(r.invoiceId)?.invoiceNumber ?? null) : null,
@@ -278,4 +289,26 @@ export class AdditionalChargeService {
}; };
}); });
} }
/**
* Amount converted to the other of ETB/USD, via the existing shared
* `ExchangeService` (CBE rate, falls back to the stored `exchange_settings`
* rate) — same mechanism `booking-wagon-cancellation.service.ts` and
* warehouse fee pricing already use. Null on anything but ETB/USD, or if
* the rate feed is down — this is a display convenience, not the payable
* amount, so a failure here must never break the charge list.
*/
private async convertAmount(
charge: AdditionalCharge,
): Promise<{ amount: number; currency: string } | null> {
if (charge.currency !== 'ETB' && charge.currency !== 'USD') return null;
const target = charge.currency === 'ETB' ? 'USD' : 'ETB';
try {
const amount = await this.exchangeService.convert(Number(charge.amount), charge.currency, target);
return { amount: Math.round(amount * 100) / 100, currency: target };
} catch (err) {
this.logger.warn(`Rate conversion failed for charge ${charge.id}: ${(err as Error).message}`);
return null;
}
}
} }

View File

@@ -63,7 +63,7 @@ describe('BookingTransitionService — paired staff decisions', () => {
expect(result.partner.id).toBe('b-2'); expect(result.partner.id).toBe('b-2');
}); });
it('cancels both halves with the same reason', async () => { it('cancels via cancel() once — its pair cascade settles the partner', async () => {
const { service } = makeService(paired); const { service } = makeService(paired);
const cancel = jest const cancel = jest
.spyOn(service, 'cancel') .spyOn(service, 'cancel')
@@ -73,21 +73,23 @@ describe('BookingTransitionService — paired staff decisions', () => {
reason: 'customer withdrew', reason: 'customer withdrew',
}); });
expect(cancel).toHaveBeenNthCalledWith(1, 'b-1', 'customer withdrew'); expect(cancel).toHaveBeenCalledTimes(1);
expect(cancel).toHaveBeenNthCalledWith(2, 'b-2', 'customer withdrew'); expect(cancel).toHaveBeenCalledWith('b-1', 'customer withdrew');
}); });
it('propagates a failure on the second half so neither is committed', async () => { it('propagates a failure on the second half so neither is committed', async () => {
const { service, dataSource } = makeService(paired); const { service, dataSource } = makeService(paired);
jest jest
.spyOn(service, 'cancel') .spyOn(service, 'acceptIntake')
.mockImplementationOnce(async (id) => ({ id }) as Booking) .mockImplementationOnce(async (id) => ({ id }) as Booking)
.mockImplementationOnce(async () => { .mockImplementationOnce(async () => {
throw new Error('partner is already in transit'); throw new Error('partner is already in transit');
}); });
await expect( await expect(
service.applyPairedDecision('b-1', 'cancel', 'staff-1', { reason: 'x' }), service.applyPairedDecision('b-1', 'accept', 'staff-1', {
validityDays: 30,
}),
).rejects.toThrow('partner is already in transit'); ).rejects.toThrow('partner is already in transit');
// Both halves ran inside one transaction, so the throw rolls the first back. // Both halves ran inside one transaction, so the throw rolls the first back.

View File

@@ -91,28 +91,10 @@ export class BookingTransitionService {
/** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */ /** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */
private async assert20ftPairable(booking: Booking): Promise<void> { private async assert20ftPairable(booking: Booking): Promise<void> {
// Parity gate. 20ft ride two per wagon, so an odd total leaves one container // Odd 20ft totals are not rejected here: runConsolidationOnSubmit (called
// that cannot be placed. Consolidation (pairing it with another customer's // right after this gate) auto-pairs the odd leftover with another
// odd booking) is built end to end but switched off for now, so an odd total // customer's odd booking or parks the booking as PENDING_CONSOLIDATION.
// is rejected here rather than parked for a partner. // Only the weight-pairing rule hard-blocks.
// containerSize is not always populated (some rows carry only the container
// type), so fall back to the type's sizeFt rather than silently skipping
// those lines and letting an odd booking through.
const ft20Quantity = (booking.bookingContainers ?? [])
.filter((bc) =>
bc.containerSize
? bc.containerSize.includes("20")
: Number(bc.containerType?.sizeFt) === 20,
)
.reduce((sum, bc) => sum + Number(bc.quantity || 0), 0);
if (ft20Quantity % 2 === 1) {
throw new BadRequestException(
`20ft containers travel two per wagon, so they must be booked in even ` +
`numbers. This booking has ${ft20Quantity} — add one more or remove ` +
`one (book ${ft20Quantity + 1} or ${ft20Quantity - 1}).`,
);
}
const violations = const violations =
await this.containerValidationService.validate20ftPairing(booking); await this.containerValidationService.validate20ftPairing(booking);
if (violations.length) { if (violations.length) {
@@ -456,11 +438,42 @@ export class BookingTransitionService {
async cancelHold(bookingId: string, reason?: string): Promise<Booking> { async cancelHold(bookingId: string, reason?: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId); const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ["SELECTED_FOR_BATCH"]); assertBookingStatus(booking, ["SELECTED_FOR_BATCH"]);
if (booking.consolidationPartnerId) { // Consolidated pair: the shared wagon dies with this hold. An unpaid
throw new BadRequestException( // partner's hold is released with it (both cancel, no fee); a PAID partner
"This booking shares a consolidated wagon with another booking — " + // keeps the whole wagon and this canceller owes the cancellation fee.
"contact support to cancel it.", const partnerId = booking.consolidationPartnerId;
if (partnerId) {
const partner = await this.bookingsService.findById(partnerId);
const partnerPaid =
partner.paymentStatus === "PAID" || partner.status === "PAID";
await this.bookingsRepository.clearConsolidationPair(
booking.id,
partnerId,
); );
if (partnerPaid) {
this.events.emit("booking.consolidation.partnerLapsed", {
expiredBookingId: booking.id,
});
} else if (!["CANCELLED", "EXPIRED"].includes(partner.status)) {
const partnerReason = "Cancelled with its consolidation partner";
await this.bookingsRepository.createReviewNote(
partnerId,
partnerReason,
"REJECTION",
);
if (partner.status === "SELECTED_FOR_BATCH") {
await this.bookingBatchService.cancelReservation(partnerId);
} else {
await this.invoiceService.expireOpenInvoices(partnerId);
await this.bookingsRepository.update(partnerId, {
status: "CANCELLED",
} as never);
}
this.notifier.cancelled(
await this.bookingsService.findById(partnerId),
partnerReason,
);
}
} }
await this.bookingsRepository.createReviewNote( await this.bookingsRepository.createReviewNote(
bookingId, bookingId,
@@ -514,6 +527,17 @@ export class BookingTransitionService {
); );
} }
// cancel() carries its own pair cascade (it settles the partner too), so
// running it twice would trip on the already-cancelled partner.
if (decision === "cancel") {
const own = await this.cancel(
bookingId,
options.reason ?? "Cancelled with its consolidation partner",
);
const other = await this.bookingsService.findById(partnerId);
return { booking: own, partner: other };
}
const runOne = async (id: string): Promise<Booking> => { const runOne = async (id: string): Promise<Booking> => {
switch (decision) { switch (decision) {
case "accept": case "accept":
@@ -525,11 +549,6 @@ export class BookingTransitionService {
); );
} }
return this.acceptIntake(id, actorId, Number(options.validityDays)); return this.acceptIntake(id, actorId, Number(options.validityDays));
case "cancel":
return this.cancel(
id,
options.reason ?? "Cancelled with its consolidation partner",
);
case "operationAccept": case "operationAccept":
return this.reviewOperationRequest(id, "ACCEPT", actorId, { return this.reviewOperationRequest(id, "ACCEPT", actorId, {
note: options.note, note: options.note,
@@ -566,8 +585,53 @@ export class BookingTransitionService {
"PENDING_APPROVAL", "PENDING_APPROVAL",
"CONTRACT_READY", "CONTRACT_READY",
"OPERATION_REQUEST_PENDING", "OPERATION_REQUEST_PENDING",
// A booking parked waiting for a consolidation partner can be walked
// away from — nothing is reserved yet.
"PENDING_CONSOLIDATION",
]); ]);
// Consolidated pair: a shared wagon never ships half-full, so cancelling
// one half settles the other too. Neither paid → both cancel, no fee. A
// PAID partner instead keeps the whole wagon and the unpaid canceller
// owes the cancellation fee (opened by the partnerLapsed listener). A
// PAID booking itself never comes through here (status gate above) — it
// cancels via wagon cancellation, where the fee machinery lives.
const partnerId = booking.consolidationPartnerId;
if (partnerId) {
const partner = await this.bookingsService.findById(partnerId);
const partnerPaid =
partner.paymentStatus === "PAID" || partner.status === "PAID";
await this.bookingsRepository.clearConsolidationPair(
booking.id,
partnerId,
);
if (partnerPaid) {
this.events.emit("booking.consolidation.partnerLapsed", {
expiredBookingId: booking.id,
});
} else if (!["CANCELLED", "EXPIRED"].includes(partner.status)) {
const partnerReason = "Cancelled with its consolidation partner";
await this.bookingsRepository.createReviewNote(
partnerId,
partnerReason,
"REJECTION",
);
await this.invoiceService.expireOpenInvoices(partnerId);
if (partner.status === "SELECTED_FOR_BATCH") {
// Reserved hold: release the wagons through the batch engine.
await this.bookingBatchService.cancelReservation(partnerId);
} else {
await this.bookingsRepository.update(partnerId, {
status: "CANCELLED",
} as never);
}
this.notifier.cancelled(
await this.bookingsService.findById(partnerId),
partnerReason,
);
}
}
await this.bookingsRepository.createReviewNote( await this.bookingsRepository.createReviewNote(
bookingId, bookingId,
reason, reason,

View File

@@ -7,6 +7,7 @@ import {
Logger, Logger,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { ExchangeService } from '@edr/api-common'; import { ExchangeService } from '@edr/api-common';
import { Freight, NotificationAudience, NotificationType } from '@edr/types'; import { Freight, NotificationAudience, NotificationType } from '@edr/types';
import { DataSource, EntityManager, In, IsNull } from 'typeorm'; import { DataSource, EntityManager, In, IsNull } from 'typeorm';
@@ -35,6 +36,7 @@ import {
import { BookingsRepository } from './bookings.repository'; import { BookingsRepository } from './bookings.repository';
import { import {
RebookCancelledWagonsDto, RebookCancelledWagonsDto,
RebookContainerLineDto,
RequestWagonCancellationDto, RequestWagonCancellationDto,
} from './dto/wagon-cancellation.dto'; } from './dto/wagon-cancellation.dto';
import { Booking } from './entities/booking.entity'; import { Booking } from './entities/booking.entity';
@@ -44,8 +46,11 @@ import {
BookingWagonCancellation, BookingWagonCancellation,
CancelledQuantities, CancelledQuantities,
CancelledUnitSnapshot, CancelledUnitSnapshot,
WAGON_CANCEL_FEE_INVOICE_TYPE,
} from './entities/booking-wagon-cancellation.entity'; } from './entities/booking-wagon-cancellation.entity';
export { WAGON_CANCEL_FEE_INVOICE_TYPE };
/** /**
* rates.rate_type of the cancellation fee — an existing rate-engine type * rates.rate_type of the cancellation fee — an existing rate-engine type
* (trigger CANCELLATION, never auto-applied to booking pricing). Staff * (trigger CANCELLATION, never auto-applied to booking pricing). Staff
@@ -58,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. */ /** `booking_container.container_size` is stored as "20ft"/"40ft" — `Number()` on it is NaN. */
const sizeFtOf = (size: string | number | null | undefined): number => const sizeFtOf = (size: string | number | null | undefined): number =>
parseInt(String(size ?? ''), 10); 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 round2 = (n: number): number => Math.round(n * 100) / 100;
const round3 = (n: number): number => Math.round(n * 1000) / 1000; const round3 = (n: number): number => Math.round(n * 1000) / 1000;
@@ -140,7 +143,29 @@ export class BookingWagonCancellationService {
creditAmount: number; creditAmount: number;
}> { }> {
const booking = await this.loadCancellableBooking(bookingId); const booking = await this.loadCancellableBooking(bookingId);
const cut = await this.resolveRequestedCut(booking, dto); // Empty dto = the whole booking ("Cancel booking" button).
const cut = this.isEmptyCut(dto)
? await this.resolveFullCut(booking)
: await this.resolveRequestedCut(booking, dto);
// Consolidated booking: preview the same rules the request enforces — a
// full cut breaks the pair (canceller fee = ceil of its fractional
// wagons); a partial cut must spare the shared wagon.
if (booking.consolidationPartnerId) {
const full = await this.resolveFullCut(booking);
if (cut.wagons >= full.wagons) {
const feeWagons = Math.ceil(cut.wagons);
const fee = await this.priceFee(booking, { ...cut, wagons: feeWagons });
return {
wagons: cut.wagons,
weightTons: cut.weightTons,
feePerWagon: fee.perWagon,
feeAmount: fee.amount,
feeCurrency: fee.currency,
creditAmount: this.creditFor(booking, Number(booking.wagonsRequired ?? 0)),
};
}
this.assertCutSparesSharedWagon(cut);
}
const fee = await this.priceFee(booking, cut); const fee = await this.priceFee(booking, cut);
return { return {
wagons: cut.wagons, wagons: cut.wagons,
@@ -158,6 +183,24 @@ export class BookingWagonCancellationService {
userId?: string, userId?: string,
): Promise<BookingWagonCancellation> { ): Promise<BookingWagonCancellation> {
const booking = await this.loadCancellableBooking(bookingId); const booking = await this.loadCancellableBooking(bookingId);
// Consolidated booking: the shared wagon itself is untouchable — its other
// half belongs to the partner. The customer may still cancel
// - the WHOLE booking (breaks the pair: both cancel, ceil/floor fees), or
// - a PARTIAL cut of their own full wagons — an EVEN number of 20ft
// containers, so the odd one stays on the shared wagon and the pair
// survives untouched.
if (booking.consolidationPartnerId) {
if (this.isEmptyCut(dto)) {
return this.cancelConsolidatedPair(booking, dto.reason ?? null, userId);
}
const full = await this.resolveFullCut(booking);
const cut = await this.resolveRequestedCut(booking, dto);
if (cut.wagons >= full.wagons) {
return this.cancelConsolidatedPair(booking, dto.reason ?? null, userId);
}
this.assertCutSparesSharedWagon(cut);
// fall through: a pair-safe partial cut rides the normal partial flow.
}
const open = await this.repo.findOpenForBooking(bookingId); const open = await this.repo.findOpenForBooking(bookingId);
if (open) { if (open) {
throw new ConflictException( throw new ConflictException(
@@ -165,7 +208,10 @@ export class BookingWagonCancellationService {
); );
} }
const cut = await this.resolveRequestedCut(booking, dto); // Empty dto = the whole booking ("Cancel booking" button).
const cut = this.isEmptyCut(dto)
? await this.resolveFullCut(booking)
: await this.resolveRequestedCut(booking, dto);
const fee = await this.priceFee(booking, cut); const fee = await this.priceFee(booking, cut);
const feeAmount = fee.amount; const feeAmount = fee.amount;
const creditAmount = this.creditFor(booking, cut.wagons); const creditAmount = this.creditFor(booking, cut.wagons);
@@ -280,6 +326,253 @@ export class BookingWagonCancellationService {
return (await this.repo.update(row.id, { status: 'WITHDRAWN' }))!; return (await this.repo.update(row.id, { status: 'WITHDRAWN' }))!;
} }
// ── Consolidated-pair cancellation ──────────────────────────────────────────
/**
* Cancel BOTH halves of a consolidated pair — a shared wagon never ships
* half-full, so a paired booking always cancels whole, together with its
* partner.
*
* Fee split (the canceller's leftover 20ft claims the shared wagon):
* canceller pays ceil(its wagons), the partner floor(its wagons) — e.g.
* 11 + 13 × 20ft = 12 wagons → canceller 7, partner 5, total 12. A PAID side
* keeps its full freight as a rebooking credit (rebooked by GL through the
* normal rebook endpoint once its fee settles); an UNPAID partner is
* cancelled with no fee and no credit.
*/
private async cancelConsolidatedPair(
booking: Booking,
reason: string | null,
userId?: string,
): Promise<BookingWagonCancellation> {
const partnerId = booking.consolidationPartnerId!;
const partner = await this.bookingsRepository.findById(partnerId);
if (!partner) {
throw new NotFoundException(`Partner booking ${partnerId} not found.`);
}
const partnerPaid =
partner.paymentStatus === 'PAID' || partner.status === 'PAID';
// Break the link first — every write below treats each side singly.
await this.bookingsRepository.clearConsolidationPair(booking.id, partnerId);
const row = await this.openConsolidationBreak(
booking,
'ceil',
this.creditFor(booking, Number(booking.wagonsRequired ?? 0)),
reason ?? 'Consolidated pair cancelled',
userId,
);
if (partnerPaid) {
await this.openConsolidationBreak(
partner,
'floor',
this.creditFor(partner, Number(partner.wagonsRequired ?? 0)),
`Cancelled with its consolidation partner ${booking.reference}`,
userId,
);
} else {
// Unpaid partner: no fee — just make sure no payable invoice stays open.
await this.billing
.expirePayable(Freight.InvoiceSource.Booking, partner.id, 'PREPAID')
.catch(() => undefined);
}
for (const b of [booking, partner]) {
await this.dataSource.getRepository(Booking).update(b.id, {
status: 'CANCELLED',
trainScheduleId: null,
requestedTrainScheduleId: null,
});
await this.detachFromSchedule(b);
}
this.notifyCustomer(
booking,
'Consolidated booking cancelled',
`${booking.reference} shared a wagon with another booking, so both are cancelled. Your paid freight is kept as credit — pay the cancellation fee to rebook.`,
);
this.notifyCustomer(
partner,
'Consolidated booking cancelled',
partnerPaid
? `${partner.reference} shared a wagon with a booking that was cancelled, so it is cancelled too. Your paid freight is kept as credit — pay the cancellation fee to rebook.`
: `${partner.reference} shared a wagon with a booking that was cancelled, so it is cancelled too. Nothing was paid — no fee applies.`,
);
this.notifyStaff(
booking,
'Consolidated pair cancelled',
`${booking.reference} + ${partner.reference}: shared-wagon pair cancelled; cancellation fee invoice(s) issued.`,
);
return row;
}
/**
* Open one side's ledger row for a consolidation break: a FULL cut whose fee
* is priced on the ceil/floor split of the cut's own FRACTIONAL wagons —
* never booking.wagonsRequired, which the contract flow persists already
* ceiled (3 × 20ft is stored as 2, not 1.5, and floor(2) would over-charge
* the partner). E.g. 1 + 3 × 20ft: canceller ceil(0.5) = 1 wagon, partner
* floor(1.5) = 1 wagon — 2 wagons total, matching the pair's real space.
* feeWagons 0 (the floor side of a lone 20ft) skips the fee entirely — the
* row goes straight to CREDIT_AVAILABLE.
*/
private async openConsolidationBreak(
booking: Booking,
mode: 'ceil' | 'floor',
creditAmount: number,
reason: string,
userId?: string,
): Promise<BookingWagonCancellation> {
const open = await this.repo.findOpenForBooking(booking.id);
if (open) {
throw new ConflictException(
`Booking ${booking.reference} already has a cancellation awaiting its fee. Pay or withdraw it first.`,
);
}
const cut = await this.resolveFullCut(booking);
const feeWagons =
mode === 'ceil' ? Math.ceil(cut.wagons) : Math.floor(cut.wagons);
// The pair is dead the moment it breaks — the wagons leave the schedule
// with the cancel itself, so T2 must not release them again.
const quantities = { ...cut.quantities, releasedAtRequest: true };
if (feeWagons <= 0) {
return this.repo.create({
bookingId: booking.id,
wagonsCancelled: cut.wagons,
weightTons: cut.weightTons,
cancelledQuantities: quantities,
creditAmount,
feeAmount: 0,
feeCurrency: booking.paymentCurrency ?? 'ETB',
status: 'CREDIT_AVAILABLE',
feePaidAt: new Date(),
reason,
requestedByUserId: userId ?? null,
});
}
const fee = await this.priceFee(booking, { ...cut, wagons: feeWagons });
const row = await this.repo.create({
bookingId: booking.id,
wagonsCancelled: cut.wagons,
weightTons: cut.weightTons,
cancelledQuantities: quantities,
creditAmount,
feeRateId: fee.rates[0].id,
feeAmount: fee.amount,
feeCurrency: fee.currency,
status: 'FEE_PENDING',
reason,
requestedByUserId: userId ?? null,
});
const invoice = await this.billing.generateInvoice({
source: Freight.InvoiceSource.Booking,
sourceId: booking.id,
type: WAGON_CANCEL_FEE_INVOICE_TYPE,
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency: fee.currency,
lines: [
{
chargeType: 'CANCELLATION_FEE',
description: `Consolidation cancellation fee — ${feeWagons} wagon(s) of booking ${booking.reference}`,
quantity: feeWagons,
unitRate: fee.perWagon,
amount: fee.amount,
currency: fee.currency,
metadata: { wagonCancellationId: row.id },
},
],
totalAmount: fee.amount,
status: Freight.InvoiceStatus.Issued,
});
return (await this.repo.update(row.id, { feeInvoiceId: invoice.id })) ?? row;
}
/** No cut named at all — the "Cancel booking" button cancelling everything. */
private isEmptyCut(dto: RequestWagonCancellationDto): boolean {
return (
!dto.containers?.length && !dto.wagonAllocationIds?.length && !dto.wagons
);
}
/**
* A partial cut on a consolidated booking must leave the shared wagon whole:
* the odd 20ft riding it stays, so the cut's 20ft count must be EVEN (whole
* own wagons only). An odd cut — including picking the shared wagon itself in
* the Wagons tab (it contributes exactly one 20ft) — is rejected.
*/
private assertCutSparesSharedWagon(cut: RequestedCut): void {
const ft20Cut = Object.entries(cut.quantities.bySize ?? {})
.filter(([size]) => sizeFtOf(size) === 20)
.reduce((sum, [, qty]) => sum + qty, 0);
if (ft20Cut % 2 === 1) {
throw new BadRequestException(
'This booking shares a wagon with another booking — the shared wagon cannot be cancelled on its own. Cancel an even number of 20ft containers (your own whole wagons), or cancel the whole booking to end the consolidation.',
);
}
}
/** The whole booking as a cut — everything it still carries. */
private async resolveFullCut(booking: Booking): Promise<RequestedCut> {
if (booking.freightType === 'CONTAINER') {
const lines = await this.dataSource.getRepository(BookingContainer).find({
where: { bookingId: booking.id },
});
const bySize = new Map<string, number>();
for (const line of lines) {
const size = line.containerSize ?? '';
bySize.set(size, (bySize.get(size) ?? 0) + Number(line.quantity ?? 0));
}
const containers = [...bySize.entries()]
.filter(([, quantity]) => quantity > 0)
.map(([containerSize, quantity]) => ({ containerSize, quantity }));
return this.resolveRequestedCut(booking, {
containers,
} as RequestWagonCancellationDto);
}
return this.resolveRequestedCut(booking, {
wagons: Number(booking.wagonsRequired ?? 0),
} as RequestWagonCancellationDto);
}
/**
* The batch engine expired an UNPAID booking whose consolidation partner had
* already PAID: the paid partner keeps the whole wagon at no extra cost; the
* lapsed side owes the cancellation fee on its own wagons — shared wagon
* included (ceil). Credit is 0 (nothing was paid); once the fee settles GL
* rebooks the customer through a normal new booking.
*/
@OnEvent('booking.consolidation.partnerLapsed')
async onConsolidationPartnerLapsed(payload: {
expiredBookingId: string;
}): Promise<void> {
try {
const booking = await this.bookingsRepository.findById(
payload.expiredBookingId,
);
if (!booking) return;
if (await this.repo.findOpenForBooking(booking.id)) return; // already charged
const row = await this.openConsolidationBreak(
booking,
'ceil',
0,
'Expired while its consolidation partner had paid — cancellation fee applies',
);
if (row.status !== 'FEE_PENDING') return; // nothing owed
this.notifyCustomer(
booking,
'Cancellation fee due',
`${booking.reference} expired unpaid while sharing a wagon with a paid booking. A cancellation fee for ${Math.ceil(Number(row.wagonsCancelled))} wagon(s) has been invoiced — settle it before booking again.`,
);
} catch (err) {
this.logger.error(
`Consolidation-lapse fee failed for booking ${payload.expiredBookingId}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
// ── T2: fee settled ───────────────────────────────────────────────────────── // ── T2: fee settled ─────────────────────────────────────────────────────────
/** /**
@@ -405,8 +698,8 @@ export class BookingWagonCancellationService {
booking, booking,
whole ? 'Booking cancelled — credit available' : 'Wagon cancellation confirmed', whole ? 'Booking cancelled — credit available' : 'Wagon cancellation confirmed',
whole whole
? `All wagons of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.` ? `All wagons of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook it on any coming train day.`
: `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`, : `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook it on any coming train day.`,
); );
} }
this.logger.log( this.logger.log(
@@ -454,22 +747,19 @@ export class BookingWagonCancellationService {
`This credit cannot be rebooked (status is ${row.status}).`, `This credit cannot be rebooked (status is ${row.status}).`,
); );
} }
// A consolidation-lapse row on an UNPAID booking carries no credit — the
// customer never paid freight, so there is nothing to redeem. Book fresh.
if (Number(row.creditAmount) <= 0) {
throw new BadRequestException(
'This cancellation has no rebooking credit — the booking was never paid. Create a new booking instead.',
);
}
const source = await this.bookingsRepository.findById(row.bookingId); const source = await this.bookingsRepository.findById(row.bookingId);
if (!source) throw new NotFoundException(`Booking ${row.bookingId} not found.`); if (!source) throw new NotFoundException(`Booking ${row.bookingId} not found.`);
if (!source.contractId) { if (!source.contractId) {
throw new BadRequestException('The original booking has no contract to rebook under.'); throw new BadRequestException('The original booking has no contract to rebook under.');
} }
// Friendly pre-check; createUnderContract re-asserts inside its own guards. const createDto = this.buildRebookDto(row, dto.scheduledDate, dto.containers);
if (
source.contractValidUntil &&
new Date(source.contractValidUntil).getTime() < Date.now()
) {
throw new BadRequestException(
'Contract validity has expired — ask EDR staff to extend the contract before rebooking.',
);
}
const createDto = this.buildRebookDto(row, dto.scheduledDate);
// Same currency as the source booking — the credit is in it. // Same currency as the source booking — the credit is in it.
createDto.paymentCurrency = source.paymentCurrency ?? undefined; createDto.paymentCurrency = source.paymentCurrency ?? undefined;
const created = await this.contractBooking.createUnderContract( const created = await this.contractBooking.createUnderContract(
@@ -479,6 +769,9 @@ export class BookingWagonCancellationService {
// System actor: carries the create-booking key so the GL gate passes on // System actor: carries the create-booking key so the GL gate passes on
// Path B (customs-clearance) contracts; harmless on Path A. // Path B (customs-clearance) contracts; harmless on Path A.
{ permissions: [{ key: FREIGHT_PERMS.contracts.createBooking }] }, { permissions: [{ key: FREIGHT_PERMS.contracts.createBooking }] },
// The freight was paid while the contract was live — the credit stays
// redeemable even after the contract's validity lapses.
{ allowExpiredContract: true },
); );
const newBookingId = created.booking.id; const newBookingId = created.booking.id;
@@ -1162,11 +1455,25 @@ export class BookingWagonCancellationService {
private buildRebookDto( private buildRebookDto(
row: BookingWagonCancellation, row: BookingWagonCancellation,
scheduledDate: string, scheduledDate: string,
overrides?: RebookContainerLineDto[],
): CreateBookingUnderContractDto { ): CreateBookingUnderContractDto {
const dto: CreateBookingUnderContractDto = { scheduledDate }; const dto: CreateBookingUnderContractDto = { scheduledDate };
const q = row.cancelledQuantities; const q = row.cancelledQuantities;
if (q.bySize && Object.keys(q.bySize).length) { 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 ?? []; const units = q.units ?? [];
dto.containers = Object.entries(q.bySize).map(([size, quantity]) => { dto.containers = Object.entries(q.bySize).map(([size, quantity]) => {
const sized = units.filter((u) => u.containerSize === size); const sized = units.filter((u) => u.containerSize === size);
@@ -1175,13 +1482,23 @@ export class BookingWagonCancellationService {
`Credit is missing unit snapshots for size ${size} (${sized.length}/${quantity}) — contact EDR support.`, `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 { return {
containerSize: size, containerSize: size,
quantity, quantity,
units: sized.map((u) => ({ // Hazardous/reefer flags always ride from the snapshot (the cargo is
containerNumber: u.containerNumber, // the same cargo); number/seal/VGM come from the override when given.
sealNumber: u.sealNumber ?? undefined, units: sized.map((u, i) => ({
vgmTons: u.vgmTons, containerNumber: replacement?.[i]?.containerNumber ?? u.containerNumber,
sealNumber: replacement
? (replacement[i]?.sealNumber ?? undefined)
: (u.sealNumber ?? undefined),
vgmTons: replacement?.[i]?.vgmTons ?? u.vgmTons,
isHazardous: u.isHazardous, isHazardous: u.isHazardous,
isReefer: u.isReefer, isReefer: u.isReefer,
})), })),

View File

@@ -596,6 +596,21 @@ export class BookingsRepository extends BaseRepository<Booking> {
} as never); } as never);
} }
/**
* Terminal un-pair: break the consolidation link only, touching neither
* status. Used when one half of a pair is cancelled/expired — the caller
* decides each side's fate ({@link unpairConsolidation} instead re-parks
* BOTH sides to PENDING_CONSOLIDATION, which is wrong for a dying booking).
*/
async clearConsolidationPair(bookingId: string, partnerId: string): Promise<void> {
await this.repository.update(bookingId, {
consolidationPartnerId: null,
} as never);
await this.repository.update(partnerId, {
consolidationPartnerId: null,
} as never);
}
/** Un-pair a consolidation. */ /** Un-pair a consolidation. */
async unpairConsolidation(bookingId: string, partnerId: string): Promise<void> { async unpairConsolidation(bookingId: string, partnerId: string): Promise<void> {
await this.repository.update(bookingId, { await this.repository.update(bookingId, {

View File

@@ -52,6 +52,7 @@ import {
FreightType, FreightType,
} from './entities/booking.entity'; } from './entities/booking.entity';
import { Booking } 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 { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
import { FileRecord } from '../files/entities/file.entity'; import { FileRecord } from '../files/entities/file.entity';
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'; import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
@@ -427,7 +428,8 @@ export class BookingsService {
'containerNumber', ci.container_number, 'containerNumber', ci.container_number,
'sealNumber', ci.seal_number, 'sealNumber', ci.seal_number,
'positionOnWagon', ci.position_on_wagon, 'positionOnWagon', ci.position_on_wagon,
'grossWeightTons', ci.gross_weight_tons 'grossWeightTons', ci.gross_weight_tons,
'sizeFt', cit.size_ft
) ORDER BY ci.position_on_wagon, ci.container_number ) ORDER BY ci.position_on_wagon, ci.container_number
) FILTER (WHERE ci.id IS NOT NULL), ) FILTER (WHERE ci.id IS NOT NULL),
'[]' '[]'
@@ -443,6 +445,7 @@ export class BookingsService {
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
LEFT JOIN freight.wagon_allocation_container_items ci LEFT JOIN freight.wagon_allocation_container_items ci
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
LEFT JOIN freight.container_types cit ON cit.id = ci.container_type_id
LEFT JOIN freight.wagon_allocation_bulk_loads bl LEFT JOIN freight.wagon_allocation_bulk_loads bl
ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL
WHERE a.booking_id = $1 AND a.deleted_at IS NULL WHERE a.booking_id = $1 AND a.deleted_at IS NULL
@@ -2260,16 +2263,25 @@ export class BookingsService {
return this.findById(booking.id); 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( async uploadDocuments(
id: string, id: string,
files: Express.Multer.File[], files: Express.Multer.File[],
): Promise<Booking> { ): Promise<Booking> {
const booking = await this.findById(id); const booking = await this.findById(id);
if (booking.status !== 'DRAFT') { if (booking.status !== 'DRAFT') {
throw new BadRequestException( const rebooked = await this.dataSource
'Documents can only be uploaded for DRAFT bookings', .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); await this.filesService.uploadMany(id, 'bookings', files);
return this.findById(id); return this.findById(id);

View File

@@ -1,9 +1,9 @@
import { import {
ConsolidationApprovalService, ConsolidationApprovalService,
CONSOLIDATION_APPROVAL_PENDING, CONSOLIDATION_APPROVAL_PENDING,
} from './consolidation-approval.service'; } from "./consolidation-approval.service";
import { ConsolidationApprovalStatus } from './entities/consolidation-approval.entity'; import { ConsolidationApprovalStatus } from "./entities/consolidation-approval.entity";
import { Booking } from './entities/booking.entity'; import { Booking } from "./entities/booking.entity";
/** /**
* The shared-wagon approval gate. Two customers' cargo on one wagon is a * The shared-wagon approval gate. Two customers' cargo on one wagon is a
@@ -14,37 +14,55 @@ import { Booking } from './entities/booking.entity';
* decision on one side of a shared wagon is meaningless without the other), and * decision on one side of a shared wagon is meaningless without the other), and
* a decided pairing cannot be decided twice. * a decided pairing cannot be decided twice.
*/ */
describe('ConsolidationApprovalService', () => { describe("ConsolidationApprovalService", () => {
const PENDING = { const PENDING = {
id: 'ap-1', id: "ap-1",
bookingId: 'b-1', bookingId: "b-1",
partnerBookingId: 'b-2', partnerBookingId: "b-2",
status: ConsolidationApprovalStatus.Pending, status: ConsolidationApprovalStatus.Pending,
requestedBy: 'gl-user', requestedBy: "gl-user",
}; };
function makeService(overrides: { function makeService(
approvals?: Partial<Record<string, jest.Mock>>; overrides: {
bookingsRepository?: Partial<Record<string, jest.Mock>>; approvals?: Partial<Record<string, jest.Mock>>;
} = {}) { bookingsRepository?: Partial<Record<string, jest.Mock>>;
bookingsService?: Partial<Record<string, jest.Mock>>;
/** Contract rows the id→reference lookup should return. */
contracts?: { id: string; reference: string }[];
/** Yard ids the caller is scoped to; null = unrestricted. */
yardScope?: string[] | null;
} = {},
) {
const approvals = { const approvals = {
findPendingForBooking: jest.fn().mockResolvedValue(null), findPendingForBooking: jest.fn().mockResolvedValue(null),
findById: jest.fn().mockResolvedValue(PENDING), findById: jest.fn().mockResolvedValue(PENDING),
create: jest.fn().mockResolvedValue({ id: 'ap-1' }), create: jest.fn().mockResolvedValue({ id: "ap-1" }),
decide: jest.fn().mockResolvedValue(true), decide: jest.fn().mockResolvedValue(true),
findQueue: jest.fn().mockResolvedValue([]), findQueuePage: jest.fn().mockResolvedValue({ items: [], total: 0 }),
countByStatus: jest
.fn()
.mockResolvedValue({ PENDING: 2, APPROVED: 4, REJECTED: 1 }),
findAllForBooking: jest.fn().mockResolvedValue([]), findAllForBooking: jest.fn().mockResolvedValue([]),
...overrides.approvals, ...overrides.approvals,
}; };
const bookingsRepository = { const bookingsRepository = {
update: jest.fn().mockResolvedValue(undefined), update: jest.fn().mockResolvedValue(undefined),
createReviewNote: jest.fn().mockResolvedValue(undefined), createReviewNote: jest.fn().mockResolvedValue(undefined),
resolveStaffNames: jest.fn().mockResolvedValue(new Map()),
...overrides.bookingsRepository, ...overrides.bookingsRepository,
}; };
const bookingsService = { const bookingsService = {
findById: jest.fn(async (id: string) => findById: jest.fn(
({ id, reference: `BK-${id}` }) as Booking, async (id: string) =>
({
id,
reference: `BK-${id}`,
originYardId: "mojo",
destinationYardId: "djibouti",
}) as Booking,
), ),
...overrides.bookingsService,
}; };
const notifier = { const notifier = {
consolidationApprovalRequestedToStaff: jest.fn(), consolidationApprovalRequestedToStaff: jest.fn(),
@@ -52,8 +70,19 @@ describe('ConsolidationApprovalService', () => {
consolidationRejectedToStaff: jest.fn(), consolidationRejectedToStaff: jest.fn(),
operationRequestedToStaff: jest.fn(), operationRequestedToStaff: jest.fn(),
}; };
const contractRepo = {
find: jest
.fn()
.mockResolvedValue(overrides.contracts ?? []),
};
const dataSource = { const dataSource = {
transaction: jest.fn(async (cb: () => Promise<unknown>) => cb()), transaction: jest.fn(async (cb: () => Promise<unknown>) => cb()),
getRepository: jest.fn(() => contractRepo),
};
const yardScope = {
getScopedYardIds: jest
.fn()
.mockResolvedValue(overrides.yardScope ?? null),
}; };
const service = new ConsolidationApprovalService( const service = new ConsolidationApprovalService(
@@ -62,27 +91,35 @@ describe('ConsolidationApprovalService', () => {
bookingsService as never, bookingsService as never,
notifier as never, notifier as never,
dataSource as never, dataSource as never,
yardScope as never,
); );
return { service, approvals, bookingsRepository, notifier }; return {
service,
approvals,
bookingsRepository,
notifier,
yardScope,
contractRepo,
};
} }
it('holds BOTH halves at the gate when a pairing is created', async () => { it("holds BOTH halves at the gate when a pairing is created", async () => {
const { service, approvals, bookingsRepository, notifier } = makeService(); const { service, approvals, bookingsRepository, notifier } = makeService();
await service.requestApproval('b-1', 'b-2', 'gl-user'); await service.requestApproval("b-1", "b-2", "gl-user");
expect(approvals.create).toHaveBeenCalledWith( expect(approvals.create).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
bookingId: 'b-1', bookingId: "b-1",
partnerBookingId: 'b-2', partnerBookingId: "b-2",
requestedBy: 'gl-user', requestedBy: "gl-user",
}), }),
); );
// Neither half may sit in the operations queue while the wagon is unreviewed. // Neither half may sit in the operations queue while the wagon is unreviewed.
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', { expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", {
status: CONSOLIDATION_APPROVAL_PENDING, status: CONSOLIDATION_APPROVAL_PENDING,
}); });
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', { expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", {
status: CONSOLIDATION_APPROVAL_PENDING, status: CONSOLIDATION_APPROVAL_PENDING,
}); });
expect( expect(
@@ -90,94 +127,102 @@ describe('ConsolidationApprovalService', () => {
).toHaveBeenCalledTimes(1); ).toHaveBeenCalledTimes(1);
}); });
it('does not open a second review for a pairing already pending', async () => { it("does not open a second review for a pairing already pending", async () => {
const { service, approvals } = makeService({ const { service, approvals } = makeService({
approvals: { approvals: {
findPendingForBooking: jest.fn().mockResolvedValue(PENDING), findPendingForBooking: jest.fn().mockResolvedValue(PENDING),
}, },
}); });
const result = await service.requestApproval('b-1', 'b-2', 'gl-user'); const result = await service.requestApproval("b-1", "b-2", "gl-user");
expect(result).toBe(PENDING); expect(result).toBe(PENDING);
expect(approvals.create).not.toHaveBeenCalled(); expect(approvals.create).not.toHaveBeenCalled();
}); });
it('releases BOTH halves to Operations on approval, logging who decided', async () => { it("releases BOTH halves to Operations on approval, logging who decided", async () => {
const { service, approvals, bookingsRepository, notifier } = makeService(); const { service, approvals, bookingsRepository, notifier } = makeService();
await service.approve('ap-1', 'approver-1', 'looks fine'); await service.approve("ap-1", "approver-1", "looks fine");
expect(approvals.decide).toHaveBeenCalledWith( expect(approvals.decide).toHaveBeenCalledWith(
'ap-1', "ap-1",
ConsolidationApprovalStatus.Approved, ConsolidationApprovalStatus.Approved,
'approver-1', "approver-1",
'looks fine', "looks fine",
[
ConsolidationApprovalStatus.Pending,
ConsolidationApprovalStatus.Rejected,
],
); );
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', { expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", {
status: 'OPERATION_REQUEST_PENDING', status: "OPERATION_REQUEST_PENDING",
}); });
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', { expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", {
status: 'OPERATION_REQUEST_PENDING', status: "OPERATION_REQUEST_PENDING",
}); });
// Operations only learns about the pair now — the gate is what kept it out. // Operations only learns about the pair now — the gate is what kept it out.
expect(notifier.operationRequestedToStaff).toHaveBeenCalledTimes(2); expect(notifier.operationRequestedToStaff).toHaveBeenCalledTimes(2);
}); });
it('sends BOTH halves back to GL on rejection, with the reason on each', async () => { it("sends BOTH halves back to GL on rejection, with the reason on each", async () => {
const { service, approvals, bookingsRepository } = makeService(); const { service, approvals, bookingsRepository } = makeService();
await service.reject('ap-1', 'approver-1', 'partner cargo is wrong'); await service.reject("ap-1", "approver-1", "partner cargo is wrong");
expect(approvals.decide).toHaveBeenCalledWith( expect(approvals.decide).toHaveBeenCalledWith(
'ap-1', "ap-1",
ConsolidationApprovalStatus.Rejected, ConsolidationApprovalStatus.Rejected,
'approver-1', "approver-1",
'partner cargo is wrong', "partner cargo is wrong",
); );
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith( expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
'b-1', "b-1",
'partner cargo is wrong', "partner cargo is wrong",
'CHANGES_REQUESTED', "CHANGES_REQUESTED",
); );
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith( expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
'b-2', "b-2",
'partner cargo is wrong', "partner cargo is wrong",
'CHANGES_REQUESTED', "CHANGES_REQUESTED",
); );
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', { expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", {
status: 'OPERATION_CHANGES_REQUESTED', status: "OPERATION_CHANGES_REQUESTED",
}); });
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', { expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", {
status: 'OPERATION_CHANGES_REQUESTED', status: "OPERATION_CHANGES_REQUESTED",
}); });
}); });
it('lets the requester approve their own pairing', async () => { it("lets the requester approve their own pairing", async () => {
// No maker-checker separation: the permission alone decides who may approve, // No maker-checker separation: the permission alone decides who may approve,
// and the audit trail still records requester and approver separately. // and the audit trail still records requester and approver separately.
const { service, approvals } = makeService(); const { service, approvals } = makeService();
await service.approve('ap-1', 'gl-user'); await service.approve("ap-1", "gl-user");
expect(approvals.decide).toHaveBeenCalledWith( expect(approvals.decide).toHaveBeenCalledWith(
'ap-1', "ap-1",
ConsolidationApprovalStatus.Approved, ConsolidationApprovalStatus.Approved,
'gl-user', "gl-user",
undefined, undefined,
[
ConsolidationApprovalStatus.Pending,
ConsolidationApprovalStatus.Rejected,
],
); );
}); });
it('requires a reason to reject', async () => { it("requires a reason to reject", async () => {
const { service, approvals } = makeService(); const { service, approvals } = makeService();
await expect(service.reject('ap-1', 'approver-1', ' ')).rejects.toThrow( await expect(service.reject("ap-1", "approver-1", " ")).rejects.toThrow(
/reason is required/i, /reason is required/i,
); );
expect(approvals.decide).not.toHaveBeenCalled(); expect(approvals.decide).not.toHaveBeenCalled();
}); });
it('refuses a pairing that was already decided', async () => { it("refuses a pairing that was already decided", async () => {
const { service, bookingsRepository } = makeService({ const { service, bookingsRepository } = makeService({
approvals: { approvals: {
findById: jest.fn().mockResolvedValue({ findById: jest.fn().mockResolvedValue({
@@ -187,21 +232,250 @@ describe('ConsolidationApprovalService', () => {
}, },
}); });
await expect(service.approve('ap-1', 'approver-1')).rejects.toThrow( await expect(service.approve("ap-1", "approver-1")).rejects.toThrow(
/already approved/i, /already approved/i,
); );
expect(bookingsRepository.update).not.toHaveBeenCalled(); expect(bookingsRepository.update).not.toHaveBeenCalled();
}); });
it('loses cleanly when another approver decides the same pairing first', async () => { it("loses cleanly when another approver decides the same pairing first", async () => {
// decide() writes only against a still-PENDING row, so the loser of the race // decide() writes only against a still-PENDING row, so the loser of the race
// affects nothing and must not move the bookings. // affects nothing and must not move the bookings.
const { service } = makeService({ const { service } = makeService({
approvals: { decide: jest.fn().mockResolvedValue(false) }, approvals: { decide: jest.fn().mockResolvedValue(false) },
}); });
await expect(service.approve('ap-1', 'approver-1')).rejects.toThrow( await expect(service.approve("ap-1", "approver-1")).rejects.toThrow(
/already decided by someone else/i, /already decided by someone else/i,
); );
}); });
it("approves a pairing that was rejected earlier, releasing both halves", async () => {
// A rejection is not final: the reviewer may change their mind, or GL may
// argue the case. Only an already-approved pairing is closed.
const { service, bookingsRepository } = makeService({
approvals: {
findById: jest.fn().mockResolvedValue({
...PENDING,
status: ConsolidationApprovalStatus.Rejected,
decidedBy: "approver-1",
}),
},
});
await service.approve("ap-1", "approver-2", "resolved with GL");
expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", {
status: "OPERATION_REQUEST_PENDING",
});
expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", {
status: "OPERATION_REQUEST_PENDING",
});
});
it("refuses to reject a pairing that was already rejected", async () => {
const { service, bookingsRepository } = makeService({
approvals: {
findById: jest.fn().mockResolvedValue({
...PENDING,
status: ConsolidationApprovalStatus.Rejected,
}),
},
});
await expect(
service.reject("ap-1", "approver-1", "still wrong"),
).rejects.toThrow(/already rejected/i);
expect(bookingsRepository.update).not.toHaveBeenCalled();
});
it("names the requester and the decider on every queue row", async () => {
// The stored ids mean nothing to a reviewer reading the history.
const { service } = makeService({
approvals: {
findQueuePage: jest.fn().mockResolvedValue({
items: [
{
...PENDING,
status: ConsolidationApprovalStatus.Approved,
decidedBy: "approver-1",
},
],
total: 1,
}),
},
bookingsRepository: {
resolveStaffNames: jest.fn().mockResolvedValue(
new Map([
["gl-user", "Selam GL"],
["approver-1", "Abebe Approver"],
]),
),
},
});
const { items, meta, counts } = await service.queue({ pageSize: 10 });
expect(items[0].requestedByName).toBe("Selam GL");
expect(items[0].decidedByName).toBe("Abebe Approver");
// Badges count the whole queue, not the page that happened to load.
expect(counts.APPROVED).toBe(4);
expect(meta).toMatchObject({
page: 1,
pageSize: 10,
total: 1,
totalPages: 1,
});
});
it("pages the queue in SQL and reports the page meta", async () => {
// The page must be cut in the query, not sliced out of a full fetch —
// otherwise ordering only holds within whatever page loaded.
const findQueuePage = jest.fn().mockResolvedValue({ items: [], total: 25 });
const { service } = makeService({ approvals: { findQueuePage } });
const { meta } = await service.queue({
status: ConsolidationApprovalStatus.Rejected,
page: 2,
pageSize: 10,
});
expect(findQueuePage).toHaveBeenCalledWith({
status: ConsolidationApprovalStatus.Rejected,
page: 2,
pageSize: 10,
});
expect(meta).toMatchObject({
page: 2,
totalPages: 3,
hasNextPage: true,
hasPreviousPage: true,
});
});
it("narrows the queue and the badges to the caller's yards", async () => {
// A Mojo + Adama desk sees both yards' pairings, and nothing else. The
// badges must be narrowed too, or they promise rows the caller cannot open.
const findQueuePage = jest.fn().mockResolvedValue({ items: [], total: 0 });
const countByStatus = jest
.fn()
.mockResolvedValue({ PENDING: 1, APPROVED: 0, REJECTED: 0 });
const { service } = makeService({
approvals: { findQueuePage, countByStatus },
yardScope: ["mojo", "adama"],
});
await service.queue({ user: { id: "u-1" }, page: 1, pageSize: 10 });
expect(findQueuePage).toHaveBeenCalledWith(
expect.objectContaining({ yardIds: ["mojo", "adama"] }),
);
expect(countByStatus).toHaveBeenCalledWith(["mojo", "adama"]);
});
it("leaves the queue unnarrowed for an unrestricted caller", async () => {
// Super admin, `yards:view_all`, or a desk with no yard mapping at all —
// the mapping narrows access, it never grants it.
const findQueuePage = jest.fn().mockResolvedValue({ items: [], total: 0 });
const { service } = makeService({
approvals: { findQueuePage },
yardScope: null,
});
await service.queue({ user: { id: "u-1" } });
expect(findQueuePage).toHaveBeenCalledWith(
expect.objectContaining({ yardIds: undefined }),
);
});
it("refuses to decide a pairing outside the caller's yards", async () => {
// Hiding the row is not enough — the id is guessable from a shared link,
// and deciding moves two other yards' bookings.
const { service, bookingsRepository } = makeService({
yardScope: ["adama"],
});
await expect(
service.approve("ap-1", "approver-1", undefined, { id: "u-1" }),
).rejects.toThrow(/outside your assigned yards/i);
expect(bookingsRepository.update).not.toHaveBeenCalled();
});
it("allows a decision when only the PARTNER half touches the caller's yard", async () => {
// The pair is one decision, so seeing one side is seeing the pairing.
const { service, bookingsRepository } = makeService({
yardScope: ["dire-dawa"],
bookingsService: {
findById: jest.fn(async (id: string) =>
id === "b-2"
? ({
id,
reference: "BK-b-2",
originYardId: "djibouti",
destinationYardId: "dire-dawa",
} as Booking)
: ({
id,
reference: "BK-b-1",
originYardId: "mojo",
destinationYardId: "djibouti",
} as Booking),
),
},
});
await service.approve("ap-1", "approver-1", undefined, { id: "u-1" });
expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", {
status: "OPERATION_REQUEST_PENDING",
});
});
it("attaches each half's contract reference for the queue link", async () => {
// Booking has no contract relation (contractbooking split), so the
// references are batch-loaded by id — one query for the whole page.
const { service, contractRepo } = makeService({
approvals: {
findQueuePage: jest.fn().mockResolvedValue({
items: [
{
...PENDING,
booking: { id: "b-1", contractId: "c-1" },
partnerBooking: { id: "b-2", contractId: "c-2" },
},
],
total: 1,
}),
},
contracts: [
{ id: "c-1", reference: "CT-001" },
{ id: "c-2", reference: "CT-002" },
],
});
const { items } = await service.queue();
expect(items[0].contractReference).toBe("CT-001");
expect(items[0].partnerContractReference).toBe("CT-002");
expect(contractRepo.find).toHaveBeenCalledTimes(1);
});
it("leaves the contract reference null when a half has no contract", async () => {
const { service, contractRepo } = makeService({
approvals: {
findQueuePage: jest.fn().mockResolvedValue({
items: [{ ...PENDING, booking: { id: "b-1" }, partnerBooking: null }],
total: 1,
}),
},
});
const { items } = await service.queue();
expect(items[0].contractReference).toBeNull();
expect(items[0].partnerContractReference).toBeNull();
// Nothing to look up — no query at all.
expect(contractRepo.find).not.toHaveBeenCalled();
});
}); });

View File

@@ -1,13 +1,14 @@
import { import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
ForbiddenException,
Inject, Inject,
Injectable, Injectable,
Logger, Logger,
NotFoundException, NotFoundException,
forwardRef, forwardRef,
} from "@nestjs/common"; } from "@nestjs/common";
import { DataSource } from "typeorm"; import { DataSource, In } from "typeorm";
import { Booking } from "./entities/booking.entity"; import { Booking } from "./entities/booking.entity";
import { import {
@@ -18,6 +19,8 @@ import { ConsolidationApprovalsRepository } from "./consolidation-approvals.repo
import { BookingsRepository } from "./bookings.repository"; import { BookingsRepository } from "./bookings.repository";
import { BookingsService } from "./bookings.service"; import { BookingsService } from "./bookings.service";
import { BookingLifecycleNotifierService } from "./booking-lifecycle-notifier.service"; import { BookingLifecycleNotifierService } from "./booking-lifecycle-notifier.service";
import { YardScopeService } from "../rule-engine/services/yard-scope.service";
import { Contract } from "../contracts/entities/contract.entity";
/** Where a rejected pair goes back to, so GL can fix and resubmit. */ /** Where a rejected pair goes back to, so GL can fix and resubmit. */
const REJECTED_STATUS = "OPERATION_CHANGES_REQUESTED"; const REJECTED_STATUS = "OPERATION_CHANGES_REQUESTED";
@@ -25,6 +28,15 @@ const REJECTED_STATUS = "OPERATION_CHANGES_REQUESTED";
/** The gate's own holding status — neither half reaches Operations from here. */ /** The gate's own holding status — neither half reaches Operations from here. */
export const CONSOLIDATION_APPROVAL_PENDING = "CONSOLIDATION_APPROVAL_PENDING"; export const CONSOLIDATION_APPROVAL_PENDING = "CONSOLIDATION_APPROVAL_PENDING";
/** An approval row with the requester's and decider's names resolved. */
export type ConsolidationApprovalView = ConsolidationApproval & {
requestedByName: string | null;
decidedByName: string | null;
/** Contract the booking half was created under — reviewers work by contract. */
contractReference: string | null;
partnerContractReference: string | null;
};
/** /**
* The shared-wagon approval gate. * The shared-wagon approval gate.
* *
@@ -53,6 +65,7 @@ export class ConsolidationApprovalService {
private readonly bookingsService: BookingsService, private readonly bookingsService: BookingsService,
private readonly notifier: BookingLifecycleNotifierService, private readonly notifier: BookingLifecycleNotifierService,
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
private readonly yardScope: YardScopeService,
) {} ) {}
/** /**
@@ -116,8 +129,16 @@ export class ConsolidationApprovalService {
approvalId: string, approvalId: string,
decidedBy: string, decidedBy: string,
note?: string, note?: string,
user?: unknown,
): Promise<{ booking: Booking; partner: Booking }> { ): Promise<{ booking: Booking; partner: Booking }> {
const approval = await this.loadPending(approvalId); // A pairing that was rejected can still be approved later — the reviewer
// changed their mind, or GL argued the case. Only an already-approved one
// is final, since both halves have moved on to Operations by then.
const approval = await this.loadDecidable(approvalId, [
ConsolidationApprovalStatus.Pending,
ConsolidationApprovalStatus.Rejected,
]);
await this.assertInScope(approval, user);
await this.dataSource.transaction(async () => { await this.dataSource.transaction(async () => {
const claimed = await this.approvals.decide( const claimed = await this.approvals.decide(
@@ -125,6 +146,10 @@ export class ConsolidationApprovalService {
ConsolidationApprovalStatus.Approved, ConsolidationApprovalStatus.Approved,
decidedBy, decidedBy,
note, note,
[
ConsolidationApprovalStatus.Pending,
ConsolidationApprovalStatus.Rejected,
],
); );
// Lost the race to another approver deciding the same pairing. // Lost the race to another approver deciding the same pairing.
if (!claimed) { if (!claimed) {
@@ -162,13 +187,17 @@ export class ConsolidationApprovalService {
approvalId: string, approvalId: string,
decidedBy: string, decidedBy: string,
reason: string, reason: string,
user?: unknown,
): Promise<{ booking: Booking; partner: Booking }> { ): Promise<{ booking: Booking; partner: Booking }> {
if (!reason?.trim()) { if (!reason?.trim()) {
throw new BadRequestException( throw new BadRequestException(
"A reason is required to reject a consolidation.", "A reason is required to reject a consolidation.",
); );
} }
const approval = await this.loadPending(approvalId); const approval = await this.loadDecidable(approvalId, [
ConsolidationApprovalStatus.Pending,
]);
await this.assertInScope(approval, user);
await this.dataSource.transaction(async () => { await this.dataSource.transaction(async () => {
const claimed = await this.approvals.decide( const claimed = await this.approvals.decide(
@@ -212,9 +241,120 @@ export class ConsolidationApprovalService {
return { booking, partner }; return { booking, partner };
} }
/** Pending pairings awaiting a decision, oldest first. */ /**
queue(): Promise<ConsolidationApproval[]> { * One page of the review queue, or of its history: pending pairings first,
return this.approvals.findQueue(); * then the decided ones, each carrying the display name of whoever requested
* and whoever decided it — the stored ids tell a reviewer nothing.
*
* `user` narrows the whole thing to the caller's yards: a Mojo desk sees the
* pairings that start or end at Mojo, a desk mapped to Mojo AND Adama sees
* both yards' pairings. The counts behind the tabs are narrowed the same way,
* so a badge never promises rows the caller cannot open.
*/
async queue(options?: {
status?: ConsolidationApprovalStatus;
page?: number;
pageSize?: number;
/** The `/auth/me` caller. Omit only for internal, unscoped reads. */
user?: unknown;
}): Promise<{
items: ConsolidationApprovalView[];
total: number;
/** Counts per status within the caller's scope — the tab badges. */
counts: Record<ConsolidationApprovalStatus, number>;
meta: {
page: number;
pageSize: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}> {
const page = Math.max(1, options?.page ?? 1);
const pageSize = Math.min(100, Math.max(1, options?.pageSize ?? 10));
const yardIds = await this.scopedYardIds(options?.user);
const { items: rows, total } = await this.approvals.findQueuePage({
status: options?.status,
yardIds,
page,
pageSize,
});
const counts = await this.approvals.countByStatus(yardIds);
const names = await this.bookingsRepository.resolveStaffNames(
rows.flatMap((r) => [r.requestedBy, r.decidedBy]),
);
const contractRefs = await this.contractReferences(rows);
const refOf = (contractId?: string | null) =>
contractId ? (contractRefs.get(contractId) ?? null) : null;
const items = rows.map((row) => ({
...row,
requestedByName: row.requestedBy
? (names.get(row.requestedBy) ?? null)
: null,
decidedByName: row.decidedBy ? (names.get(row.decidedBy) ?? null) : null,
contractReference: refOf(row.booking?.contractId),
partnerContractReference: refOf(row.partnerBooking?.contractId),
}));
const totalPages = Math.ceil(total / pageSize);
return {
items,
total,
counts,
meta: {
page,
pageSize,
total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
};
}
/**
* Contract id → reference for the bookings on this page.
*
* Booking has no contract relation (contractbooking split), so the
* references are batch-loaded by id rather than joined — one query per page,
* not one per row.
*/
private async contractReferences(
rows: ConsolidationApproval[],
): Promise<Map<string, string>> {
const ids = [
...new Set(
rows
.flatMap((r) => [r.booking?.contractId, r.partnerBooking?.contractId])
.filter((id): id is string => !!id),
),
];
if (!ids.length) return new Map();
const contracts = await this.dataSource.getRepository(Contract).find({
where: { id: In(ids) },
select: { id: true, reference: true },
});
return new Map(contracts.map((c) => [c.id, c.reference]));
}
/**
* Yard ids the caller may see, or undefined for unrestricted.
*
* Scope comes from the desk they are logged in as: `yard_positions` maps a
* position to its yards, so a Mojo CEO resolves to [Mojo]. A super admin, a
* `yards:view_all` holder, and a desk with NO yard mapping all resolve to
* unrestricted — the mapping narrows access, it never grants it.
*
* Called with no user only from internal paths, which are unscoped.
*/
private async scopedYardIds(user: unknown): Promise<string[] | undefined> {
if (!user) return undefined;
const scope = await this.yardScope.getScopedYardIds(user as never);
return scope ?? undefined;
} }
/** Full decision history for one booking — who decided what, and when. */ /** Full decision history for one booking — who decided what, and when. */
@@ -227,12 +367,46 @@ export class ConsolidationApprovalService {
return this.approvals.findPendingForBooking(bookingId); return this.approvals.findPendingForBooking(bookingId);
} }
private async loadPending(approvalId: string): Promise<ConsolidationApproval> { /**
* Refuse a decision on a pairing outside the caller's yards.
*
* Hiding the row from the list is not enough on its own: the id is guessable
* from a shared link, and deciding a pairing moves two other yards' bookings.
* Same rule as the list — either half's origin or destination is enough.
*/
private async assertInScope(
approval: ConsolidationApproval,
user: unknown,
): Promise<void> {
const yardIds = await this.scopedYardIds(user);
if (!yardIds) return;
const booking = await this.bookingsService.findById(approval.bookingId);
const partner = await this.bookingsService.findById(
approval.partnerBookingId,
);
const touches = (b: Booking | null | undefined) =>
!!b &&
(yardIds.includes(b.originYardId) ||
yardIds.includes(b.destinationYardId));
if (!touches(booking) && !touches(partner)) {
throw new ForbiddenException(
"This shared wagon is outside your assigned yards.",
);
}
}
/** Load a row and refuse it unless it is in one of the decidable states. */
private async loadDecidable(
approvalId: string,
allowed: ConsolidationApprovalStatus[],
): Promise<ConsolidationApproval> {
const approval = await this.approvals.findById(approvalId); const approval = await this.approvals.findById(approvalId);
if (!approval) { if (!approval) {
throw new NotFoundException(`Approval ${approvalId} not found`); throw new NotFoundException(`Approval ${approvalId} not found`);
} }
if (approval.status !== ConsolidationApprovalStatus.Pending) { if (!allowed.includes(approval.status)) {
throw new ConflictException( throw new ConflictException(
`This consolidation was already ${approval.status.toLowerCase()}.`, `This consolidation was already ${approval.status.toLowerCase()}.`,
); );

View File

@@ -1,11 +1,41 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { DataSource, In, Repository } from "typeorm"; import { DataSource, In, Repository, SelectQueryBuilder } from "typeorm";
import { import {
ConsolidationApproval, ConsolidationApproval,
ConsolidationApprovalStatus, ConsolidationApprovalStatus,
} from "./entities/consolidation-approval.entity"; } from "./entities/consolidation-approval.entity";
/**
* Narrow a queue query to the caller's yards.
*
* A shared wagon is visible when EITHER half of it starts or ends at one of
* those yards — the pairing is one decision, so seeing one side is seeing the
* pairing. Yards the train merely passes through do not count: only the two
* bookings' own endpoints do.
*
* `undefined` means unrestricted and adds no predicate. An EMPTY array means
* scoped-to-nothing and must match no rows — `IN ()` is not valid SQL, so it
* gets an explicit false instead of being skipped.
*/
function applyYardScope(
qb: SelectQueryBuilder<ConsolidationApproval>,
yardIds: string[] | undefined,
): void {
if (!yardIds) return;
if (!yardIds.length) {
qb.andWhere("1 = 0");
return;
}
qb.andWhere(
`(booking.originYardId IN (:...yardIds)
OR booking.destinationYardId IN (:...yardIds)
OR partnerBooking.originYardId IN (:...yardIds)
OR partnerBooking.destinationYardId IN (:...yardIds))`,
{ yardIds },
);
}
/** /**
* Persistence for the shared-wagon approval gate. Rows are never deleted — * Persistence for the shared-wagon approval gate. Rows are never deleted —
* decided rows are the audit trail of who approved which pairing and when. * decided rows are the audit trail of who approved which pairing and when.
@@ -48,16 +78,91 @@ export class ConsolidationApprovalsRepository {
return this.repository.findOne({ where: { id } }); return this.repository.findOne({ where: { id } });
} }
/** Pending requests for the review queue, oldest first (FIFO). */ /**
findQueue(): Promise<ConsolidationApproval[]> { * One page of review-queue rows, with both bookings loaded.
return this.repository.find({ *
where: { status: ConsolidationApprovalStatus.Pending }, * Pending rows are work still to do, so they come oldest first (FIFO) and
relations: { * ahead of everything else. Decided rows are history, so they come
booking: { company: true }, * newest-decision-first. Ordering is done in SQL, not after the fact — a page
partnerBooking: { company: true }, * sorted in memory would only be sorted within itself.
}, *
order: { requestedAt: "ASC" }, * `yardIds` narrows to the caller's yards (see YardScopeService); pass
}); * undefined for an unrestricted caller. The narrowing is a WHERE, not a
* post-filter, so the page and the total both count only visible rows.
*/
async findQueuePage(options: {
status?: ConsolidationApprovalStatus;
yardIds?: string[];
page: number;
pageSize: number;
}): Promise<{ items: ConsolidationApproval[]; total: number }> {
const { status, yardIds, page, pageSize } = options;
const qb = this.repository
.createQueryBuilder("approval")
.leftJoinAndSelect("approval.booking", "booking")
.leftJoinAndSelect("booking.company", "company")
.leftJoinAndSelect("approval.partnerBooking", "partnerBooking")
.leftJoinAndSelect("partnerBooking.company", "partnerCompany");
if (status) {
qb.andWhere("approval.status = :status", { status });
} else {
qb.addOrderBy(
`CASE WHEN approval.status = '${ConsolidationApprovalStatus.Pending}' THEN 0 ELSE 1 END`,
"ASC",
);
}
applyYardScope(qb, yardIds);
// Pending has no decidedAt, decided rows all do — one pair of keys orders
// both groups correctly whichever tab asked.
const [items, total] = await qb
.addOrderBy("approval.decidedAt", "DESC", "NULLS FIRST")
.addOrderBy("approval.requestedAt", "ASC")
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
return { items, total };
}
/**
* Row count per status, for the tab badges — those must show the whole
* queue, not just the page currently loaded. Narrowed by the same yard scope
* as the list, so a badge never promises rows the caller cannot open.
*/
async countByStatus(
yardIds?: string[],
): Promise<Record<ConsolidationApprovalStatus, number>> {
const qb = this.repository
.createQueryBuilder("approval")
.select("approval.status", "status")
.addSelect("COUNT(*)", "count")
.groupBy("approval.status");
// The scope predicate reads both bookings, so it needs them joined even
// though the count itself selects no columns from them.
if (yardIds) {
qb.leftJoin("approval.booking", "booking").leftJoin(
"approval.partnerBooking",
"partnerBooking",
);
}
applyYardScope(qb, yardIds);
const rows = await qb.getRawMany<{
status: ConsolidationApprovalStatus;
count: string;
}>();
const counts = {
[ConsolidationApprovalStatus.Pending]: 0,
[ConsolidationApprovalStatus.Approved]: 0,
[ConsolidationApprovalStatus.Rejected]: 0,
};
for (const row of rows) counts[row.status] = Number(row.count);
return counts;
} }
create(input: { create(input: {
@@ -89,9 +194,11 @@ export class ConsolidationApprovalsRepository {
| ConsolidationApprovalStatus.Rejected, | ConsolidationApprovalStatus.Rejected,
decidedBy: string | null, decidedBy: string | null,
decisionNote?: string | null, decisionNote?: string | null,
/** Statuses the row may be claimed FROM. Defaults to pending-only. */
from: ConsolidationApprovalStatus[] = [ConsolidationApprovalStatus.Pending],
): Promise<boolean> { ): Promise<boolean> {
const result = await this.repository.update( const result = await this.repository.update(
{ id, status: ConsolidationApprovalStatus.Pending }, { id, status: In(from) },
{ {
status, status,
decidedBy, decidedBy,
@@ -109,7 +216,10 @@ export class ConsolidationApprovalsRepository {
if (bookingIds.length === 0) return Promise.resolve([]); if (bookingIds.length === 0) return Promise.resolve([]);
return this.repository.find({ return this.repository.find({
where: [ where: [
{ bookingId: In(bookingIds), status: ConsolidationApprovalStatus.Pending }, {
bookingId: In(bookingIds),
status: ConsolidationApprovalStatus.Pending,
},
{ {
partnerBookingId: In(bookingIds), partnerBookingId: In(bookingIds),
status: ConsolidationApprovalStatus.Pending, status: ConsolidationApprovalStatus.Pending,

View File

@@ -1,6 +1,14 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsIn, IsNumber, IsOptional, IsPositive, IsString, Length } from 'class-validator'; import {
IsDateString,
IsIn,
IsNumber,
IsOptional,
IsPositive,
IsString,
Length,
} from 'class-validator';
export class CreateAdditionalChargeDto { export class CreateAdditionalChargeDto {
@ApiProperty({ example: 'Re-weighing fee at Mojo dry port' }) @ApiProperty({ example: 'Re-weighing fee at Mojo dry port' })
@@ -24,6 +32,12 @@ export class CreateAdditionalChargeDto {
@IsOptional() @IsOptional()
@IsIn(['draft', 'send']) @IsIn(['draft', 'send'])
action?: 'draft' | 'send'; action?: 'draft' | 'send';
/** Payment due date; omit to fall back to the invoice's own default term (14 days) on send. */
@ApiPropertyOptional({ example: '2026-09-01' })
@IsOptional()
@IsDateString()
dueDate?: string;
} }
export class CancelAdditionalChargeDto { export class CancelAdditionalChargeDto {

View File

@@ -67,10 +67,60 @@ export class RequestWagonCancellationDto {
reason?: string; 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 { export class RebookCancelledWagonsDto {
@ApiProperty({ description: 'Shipment day the credit is rebooked onto (ISO date)' }) @ApiProperty({ description: 'Shipment day the credit is rebooked onto (ISO date)' })
@IsDateString() @IsDateString()
scheduledDate!: string; 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 { export class FilterWagonCancellationsDto {

View File

@@ -39,6 +39,10 @@ export class AdditionalCharge extends BaseEntity {
@Column({ name: 'currency', type: 'varchar', length: 8 }) @Column({ name: 'currency', type: 'varchar', length: 8 })
currency!: string; currency!: string;
/** Optional payment due date; unset falls back to the invoice's own default term on send. */
@Column({ name: 'due_at', type: 'timestamptz', nullable: true })
dueAt?: Date | null;
/** The supporting attachment (FileRecord), if any. */ /** The supporting attachment (FileRecord), if any. */
@Column({ name: 'file_record_id', type: 'uuid', nullable: true }) @Column({ name: 'file_record_id', type: 'uuid', nullable: true })
fileRecordId?: string | null; fileRecordId?: string | null;

View File

@@ -5,6 +5,9 @@ import { Invoice } from '../../billing/entities/invoice.entity';
import { Rate } from '../../rule-engine/entities/rate.entity'; import { Rate } from '../../rule-engine/entities/rate.entity';
import { Booking } from './booking.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 = [ export const WAGON_CANCELLATION_STATUSES = [
// Requested; fee invoice open; wagons still allocated to the customer. // Requested; fee invoice open; wagons still allocated to the customer.
'FEE_PENDING', 'FEE_PENDING',

View File

@@ -3,6 +3,10 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { BaseRepository } from '@edr/api-common'; import { BaseRepository } from '@edr/api-common';
import { Company } from './entities/company.entity'; import { Company } from './entities/company.entity';
import {
companyDraftSql,
companyPendingChangeRequestSql,
} from './company-scope.sql';
import { ListCompaniesQueryDto } from './dto/list-companies-query.dto'; import { ListCompaniesQueryDto } from './dto/list-companies-query.dto';
import { CompanyStatsResponseDto } from './dto/company-stats-response.dto'; import { CompanyStatsResponseDto } from './dto/company-stats-response.dto';
@@ -15,31 +19,10 @@ export class CompaniesRepository extends BaseRepository<Company> {
* placeholder name + TIN, so it must not be offered up for review. * placeholder name + TIN, so it must not be offered up for review.
* Staff-created companies have no external profiles and are never drafts. * Staff-created companies have no external profiles and are never drafts.
*/ */
private static readonly DRAFT_SQL = `( private static readonly DRAFT_SQL = companyDraftSql('company');
EXISTS (
SELECT 1 FROM freight.external_profiles ep
WHERE ep.company_id = company.id
AND ep.deleted_at IS NULL
)
AND NOT EXISTS (
SELECT 1 FROM freight.external_profiles ep
WHERE ep.company_id = company.id
AND ep.deleted_at IS NULL
AND ep.onboarding_completed = true
)
)`;
/** private static readonly PENDING_CHANGE_REQUEST_SQL =
* A company waiting on a reviewer to decide an edit it submitted after being companyPendingChangeRequestSql('company');
* approved. These rows are `status = active`, so the pending-application filter
* can never surface them — the review queue needs its own predicate.
*/
private static readonly PENDING_CHANGE_REQUEST_SQL = `EXISTS (
SELECT 1 FROM freight.company_change_request ccr
WHERE ccr.company_id = company.id
AND ccr.status = 'pending'
AND ccr.deleted_at IS NULL
)`;
/** /**
* The `sortBy = 'review'` queue ordering: whatever marketing must act on * The `sortBy = 'review'` queue ordering: whatever marketing must act on
@@ -96,6 +79,9 @@ export class CompaniesRepository extends BaseRepository<Company> {
type, type,
kind, kind,
status, status,
nationality,
createdFrom,
createdTo,
onboardingCompleted, onboardingCompleted,
hasPendingChangeRequest, hasPendingChangeRequest,
sortBy = 'review', sortBy = 'review',
@@ -122,6 +108,18 @@ export class CompaniesRepository extends BaseRepository<Company> {
qb.andWhere('company.status = :status', { status }); qb.andWhere('company.status = :status', { status });
} }
if (nationality) {
qb.andWhere('company.nationality = :nationality', { nationality });
}
if (createdFrom) {
qb.andWhere('company.createdAt >= :createdFrom', { createdFrom });
}
if (createdTo) {
qb.andWhere('company.createdAt <= :createdTo', { createdTo });
}
if (onboardingCompleted !== undefined) { if (onboardingCompleted !== undefined) {
qb.andWhere( qb.andWhere(
onboardingCompleted onboardingCompleted

View File

@@ -0,0 +1,40 @@
/**
* Two predicates that define a customer's review state but are NOT columns on
* `companies`. Shared verbatim by the list repository and the export dataset —
* the backoffice offers both as one Status filter, so an export that computed
* "onboarding draft" differently from the list would quietly disagree with the
* screen it was launched from.
*
* Each takes the query's table alias because the two callers use different
* ones (`company` in the repository, `c` in the dataset).
*/
/**
* Still in the portal onboarding wizard: has at least one external profile,
* none of them submitted. Such a row exists from the wizard's first click, so
* it must be excluded from the awaiting-approval queue.
*/
export const companyDraftSql = (alias: string): string => `(
EXISTS (
SELECT 1 FROM freight.external_profiles ep
WHERE ep.company_id = ${alias}.id
AND ep.deleted_at IS NULL
)
AND NOT EXISTS (
SELECT 1 FROM freight.external_profiles ep
WHERE ep.company_id = ${alias}.id
AND ep.deleted_at IS NULL
AND ep.onboarding_completed = true
)
)`;
/**
* An already-approved customer who edited their profile: they stay
* `status = active`, so no status filter can ever surface them.
*/
export const companyPendingChangeRequestSql = (alias: string): string => `EXISTS (
SELECT 1 FROM freight.company_change_request ccr
WHERE ccr.company_id = ${alias}.id
AND ccr.status = 'pending'
AND ccr.deleted_at IS NULL
)`;

View File

@@ -1,7 +1,20 @@
import { ApiPropertyOptional } from "@nestjs/swagger"; import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Min } from "class-validator"; import {
IsBoolean,
IsDateString,
IsIn,
IsInt,
IsOptional,
IsString,
Min,
} from "class-validator";
import { Transform } from "class-transformer"; import { Transform } from "class-transformer";
import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity"; import {
CompanyKind,
CompanyNationality,
CompanyStatus,
CompanyType,
} from "../entities/company.entity";
export class ListCompaniesQueryDto { export class ListCompaniesQueryDto {
@ApiPropertyOptional({ default: 1 }) @ApiPropertyOptional({ default: 1 })
@@ -38,6 +51,21 @@ export class ListCompaniesQueryDto {
@IsIn(Object.values(CompanyStatus)) @IsIn(Object.values(CompanyStatus))
status?: CompanyStatus; status?: CompanyStatus;
@ApiPropertyOptional({ enum: CompanyNationality })
@IsOptional()
@IsIn(Object.values(CompanyNationality))
nationality?: CompanyNationality;
@ApiPropertyOptional({ description: "Registered on or after this instant (ISO)." })
@IsOptional()
@IsDateString()
createdFrom?: string;
@ApiPropertyOptional({ description: "Registered on or before this instant (ISO)." })
@IsOptional()
@IsDateString()
createdTo?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: description:
"Filter by onboarding submission. `true` = reviewable applications; " + "Filter by onboarding submission. `true` = reviewable applications; " +

View File

@@ -140,6 +140,14 @@ export class ContractBookingService {
dto: CreateBookingUnderContractDto, dto: CreateBookingUnderContractDto,
user?: { id?: string } | null, user?: { id?: string } | null,
actorPermissions?: unknown, actorPermissions?: unknown,
opts?: {
/**
* Wagon-cancellation credit rebook only: the freight was paid while the
* contract was live, so redeeming the credit is allowed even after the
* contract's validity lapsed. Never set for a genuinely new booking.
*/
allowExpiredContract?: boolean;
},
): Promise<CreateBookingUnderContractResult> { ): Promise<CreateBookingUnderContractResult> {
const contract = await this.contractsRepository.findByIdWithRelations(contractId); const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`); if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
@@ -180,8 +188,13 @@ export class ContractBookingService {
actorPermissions != null && actorPermissions != null &&
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking); hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
await this.assertNotExpired(contract); if (!opts?.allowExpiredContract) await this.assertNotExpired(contract);
const createdByRole = await this.assertGate(contract, isGlActor); const createdByRole = await this.assertGate(
contract,
isGlActor,
false,
opts?.allowExpiredContract,
);
// ONE_TIME: a single shipment at a time. The slot frees only if the prior // ONE_TIME: a single shipment at a time. The slot frees only if the prior
// booking reached a terminal state (e.g. payment expired without shipping), // booking reached a terminal state (e.g. payment expired without shipping),
@@ -1203,6 +1216,7 @@ export class ContractBookingService {
contract: Contract, contract: Contract,
isGlActor: boolean, isGlActor: boolean,
isInitiate = false, isInitiate = false,
allowExpired = false,
): Promise<string> { ): Promise<string> {
// Suspended contracts are frozen for everyone, GL included — say so instead // Suspended contracts are frozen for everyone, GL included — say so instead
// of letting the executed-status check below give a misleading reason. // of letting the executed-status check below give a misleading reason.
@@ -1225,7 +1239,10 @@ export class ContractBookingService {
} }
// No contract clearance cycle exists on either kind now — clearance runs // No contract clearance cycle exists on either kind now — clearance runs
// on the booking, so an executed/active contract is the only gate here. // on the booking, so an executed/active contract is the only gate here.
if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) { if (
!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status) &&
!(allowExpired && contract.status === 'EXPIRED')
) {
throw new BadRequestException( throw new BadRequestException(
'Contract must be fully executed before booking a shipment.', 'Contract must be fully executed before booking a shipment.',
); );
@@ -1234,7 +1251,10 @@ export class ContractBookingService {
} }
// Path A — customer (or staff) once the contract is executed. // Path A — customer (or staff) once the contract is executed.
if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) { if (
!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status) &&
!(allowExpired && contract.status === 'EXPIRED')
) {
throw new BadRequestException( throw new BadRequestException(
'Contract must be fully executed before booking a shipment.', 'Contract must be fully executed before booking a shipment.',
); );
@@ -2458,22 +2478,12 @@ export class ContractBookingService {
private async assert20ftPairableAtCreate( private async assert20ftPairableAtCreate(
dto: CreateBookingUnderContractDto, dto: CreateBookingUnderContractDto,
): Promise<void> { ): Promise<void> {
// Parity gate. 20ft containers ride two per wagon, so an odd total leaves // Odd 20ft totals are no longer rejected here: the wagon consolidation gate
// one container that cannot be placed. Consolidation (pairing it with // that runs right after (consolidateDrawdown / needsConsolidationFromBooking,
// another customer's odd booking) is built end to end but switched off for // same machinery the plain booking flow already uses live) auto-pairs an odd
// now, so an odd total is rejected outright — server-side, because the // total with another customer's odd booking or parks it as
// frontend block alone is not a guarantee. // PENDING_CONSOLIDATION until one appears. This assert now only checks that
const ft20Quantity = (dto.containers ?? []) // any 20ft containers actually present can be weight-paired on a wagon.
.filter((line) => (line.containerSize ?? '').includes('20'))
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
if (ft20Quantity % 2 === 1) {
throw new BadRequestException(
`20ft containers travel two per wagon, so they must be booked in even ` +
`numbers. This booking has ${ft20Quantity} — add one more or remove ` +
`one (book ${ft20Quantity + 1} or ${ft20Quantity - 1}).`,
);
}
const twentyFtUnits = (dto.containers ?? []) const twentyFtUnits = (dto.containers ?? [])
.filter((line) => (line.containerSize ?? '').includes('20')) .filter((line) => (line.containerSize ?? '').includes('20'))
.flatMap((line, lineIdx) => .flatMap((line, lineIdx) =>

View File

@@ -0,0 +1,65 @@
import { ContractBookingService } from './contract-booking.service';
/**
* Wagon-cancellation credit rebook must work after the contract lapses (the
* freight was paid while it was live), while every other create path stays
* blocked. assertGate is the status gate createUnderContract runs; this pins
* the EXPIRED carve-out to the allowExpired flag.
*/
describe('ContractBookingService.assertGate expired-contract rebook carve-out', () => {
// assertGate only reads contract fields — no constructor deps needed.
const service = Object.create(
ContractBookingService.prototype,
) as ContractBookingService;
const gate = (
contract: Record<string, unknown>,
allowExpired: boolean,
): Promise<string> =>
(
service as unknown as {
assertGate: (
c: unknown,
gl: boolean,
init: boolean,
allowExpired: boolean,
) => Promise<string>;
}
).assertGate(contract, true, false, allowExpired);
it('refuses an EXPIRED contract on the normal create path', async () => {
await expect(
gate({ status: 'EXPIRED', contractKind: 'GENERAL' }, false),
).rejects.toThrow(/fully executed/i);
});
it('lets a credit rebook through on an EXPIRED contract (Path A)', async () => {
await expect(
gate({ status: 'EXPIRED', contractKind: 'GENERAL' }, true),
).resolves.toBe('STAFF');
});
it('lets a credit rebook through on an EXPIRED customs contract (Path B)', async () => {
await expect(
gate(
{
status: 'EXPIRED',
contractKind: 'GENERAL',
customsClearingEnabled: true,
},
true,
),
).resolves.toBe('GL_ET');
});
it('still refuses a SUSPENDED contract even for a rebook', async () => {
await expect(
gate({ status: 'SUSPENDED', contractKind: 'GENERAL' }, true),
).rejects.toThrow(/suspended/i);
});
it('does not open the gate for other non-executed statuses', async () => {
await expect(
gate({ status: 'DRAFT', contractKind: 'GENERAL' }, true),
).rejects.toThrow(/fully executed/i);
});
});

View File

@@ -13,7 +13,7 @@ import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
import { ExportDataset } from '../export.types'; import { ExportDataset } from '../export.types';
/** /**
* Domain semantics shared with `reports/definitions/bookings-list.report.ts`. * Domain semantics that the retired `bookings-list` report used to share.
* Kept identical on purpose — for PER_ITEM bulk bookings `cargo_total_weight_vgm` * Kept identical on purpose — for PER_ITEM bulk bookings `cargo_total_weight_vgm`
* holds an item COUNT, not tonnage, and `adjusted_total_amount` silently * holds an item COUNT, not tonnage, and `adjusted_total_amount` silently
* overrides `total_amount`. Getting either wrong misreports money or weight. * overrides `total_amount`. Getting either wrong misreports money or weight.

View File

@@ -1,5 +1,9 @@
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
import { Company } from '../../companies/entities/company.entity'; import { Company } from '../../companies/entities/company.entity';
import {
companyDraftSql,
companyPendingChangeRequestSql,
} from '../../companies/company-scope.sql';
import { ExportDataset } from '../export.types'; import { ExportDataset } from '../export.types';
/** /**
@@ -114,6 +118,21 @@ export const customersDataset: ExportDataset = {
{ value: 'government', label: 'Government' }, { value: 'government', label: 'Government' },
] }, ] },
{ key: 'status', label: 'Status', type: 'text' }, { key: 'status', label: 'Status', type: 'text' },
{ key: 'nationality', label: 'Nationality', type: 'select', options: [
{ value: 'ethiopian', label: 'Ethiopian' },
{ value: 'foreign', label: 'Foreign' },
] },
// The list's Status filter folds the review queues in, and sends these two
// alongside `status`. They are predicates, not columns — see
// `company-scope.sql.ts`, shared with the list so both agree exactly.
{ key: 'onboardingCompleted', label: 'Onboarding submitted', type: 'select', options: [
{ value: 'true', label: 'Submitted' },
{ value: 'false', label: 'Still a draft' },
] },
{ key: 'hasPendingChangeRequest', label: 'Pending profile changes', type: 'select', options: [
{ value: 'true', label: 'Awaiting review' },
{ value: 'false', label: 'None open' },
] },
{ key: 'search', label: 'Search name, TIN or email', type: 'text' }, { key: 'search', label: 'Search name, TIN or email', type: 'text' },
], ],
@@ -127,6 +146,15 @@ export const customersDataset: ExportDataset = {
if (params.type) qb.andWhere('c.type = :type', { type: params.type }); if (params.type) qb.andWhere('c.type = :type', { type: params.type });
if (params.kind) qb.andWhere('c.kind = :kind', { kind: params.kind }); if (params.kind) qb.andWhere('c.kind = :kind', { kind: params.kind });
if (params.status) qb.andWhere('c.status = :status', { status: params.status }); if (params.status) qb.andWhere('c.status = :status', { status: params.status });
if (params.nationality) qb.andWhere('c.nationality = :nationality', { nationality: params.nationality });
if (params.onboardingCompleted) {
const draft = companyDraftSql('c');
qb.andWhere(params.onboardingCompleted === 'true' ? `NOT ${draft}` : draft);
}
if (params.hasPendingChangeRequest) {
const pending = companyPendingChangeRequestSql('c');
qb.andWhere(params.hasPendingChangeRequest === 'true' ? pending : `NOT ${pending}`);
}
if (params.search) { if (params.search) {
qb.andWhere('(c.name ILIKE :search OR c.tin ILIKE :search OR c.email ILIKE :search)', { qb.andWhere('(c.name ILIKE :search OR c.tin ILIKE :search OR c.email ILIKE :search)', {
search: `%${params.search as string}%`, search: `%${params.search as string}%`,

View File

@@ -97,14 +97,21 @@ export const invoicesDataset: ExportDataset = {
filters: [ filters: [
{ key: 'issued', label: 'Issued', type: 'daterange' }, { key: 'issued', label: 'Issued', type: 'daterange' },
{ key: 'due', label: 'Due', type: 'daterange' },
{ key: 'statuses', label: 'Status', type: 'multiselect' }, { key: 'statuses', label: 'Status', type: 'multiselect' },
// The invoices list page sends a single `status`; accept both so its // The invoices list page sends a single `status`; accept both so its
// on-screen filter actually carries into the export. // on-screen filter actually carries into the export.
{ key: 'status', label: 'Status (single)', type: 'text' }, { key: 'status', label: 'Status (single)', type: 'text' },
{ key: 'sources', label: 'Source', type: 'multiselect' },
{ key: 'eimsStatuses', label: 'EIMS status', type: 'multiselect' },
{ key: 'currency', label: 'Currency', type: 'select', options: [ { key: 'currency', label: 'Currency', type: 'select', options: [
{ value: 'ETB', label: 'ETB' }, { value: 'ETB', label: 'ETB' },
{ value: 'USD', label: 'USD' }, { value: 'USD', label: 'USD' },
] }, ] },
{ key: 'minAmount', label: 'Min total', type: 'text' },
{ key: 'maxAmount', label: 'Max total', type: 'text' },
{ key: 'hasBalance', label: 'Outstanding only', type: 'text' },
{ key: 'overdue', label: 'Overdue only', type: 'text' },
{ key: 'companyId', label: 'Customer', type: 'text' }, { key: 'companyId', label: 'Customer', type: 'text' },
{ key: 'search', label: 'Search invoice no. or customer', type: 'text' }, { key: 'search', label: 'Search invoice no. or customer', type: 'text' },
], ],
@@ -116,10 +123,27 @@ export const invoicesDataset: ExportDataset = {
qb.andWhere('i.deleted_at IS NULL'); qb.andWhere('i.deleted_at IS NULL');
if (params.issuedFrom) qb.andWhere('i.issued_at >= :issuedFrom', { issuedFrom: params.issuedFrom }); if (params.issuedFrom) qb.andWhere('i.issued_at >= :issuedFrom', { issuedFrom: params.issuedFrom });
if (params.issuedTo) qb.andWhere('i.issued_at < :issuedTo', { issuedTo: params.issuedTo }); if (params.issuedTo) qb.andWhere('i.issued_at < :issuedTo', { issuedTo: params.issuedTo });
if (params.dueFrom) qb.andWhere('i.due_at >= :dueFrom', { dueFrom: params.dueFrom });
if (params.dueTo) qb.andWhere('i.due_at < :dueTo', { dueTo: params.dueTo });
const statuses = params.statuses as string[] | null; const statuses = params.statuses as string[] | null;
if (statuses?.length) qb.andWhere('i.status IN (:...statuses)', { statuses }); if (statuses?.length) qb.andWhere('i.status IN (:...statuses)', { statuses });
if (params.status) qb.andWhere('i.status = :status', { status: params.status }); if (params.status) qb.andWhere('i.status = :status', { status: params.status });
if (params.currency) qb.andWhere('i.currency = :currency', { currency: params.currency }); const sources = params.sources as string[] | null;
if (sources?.length) qb.andWhere('i.source IN (:...sources)', { sources });
const eimsStatuses = params.eimsStatuses as string[] | null;
if (eimsStatuses?.length) qb.andWhere('i.eims_status IN (:...eimsStatuses)', { eimsStatuses });
// Casing has drifted in the data ("usd" rows exist) — normalise both sides,
// same as the list endpoint does.
if (params.currency) {
qb.andWhere('UPPER(i.currency) = :currency', {
currency: String(params.currency).toUpperCase(),
});
}
if (params.minAmount) qb.andWhere('i.total_amount >= :minAmount', { minAmount: Number(params.minAmount) });
if (params.maxAmount) qb.andWhere('i.total_amount <= :maxAmount', { maxAmount: Number(params.maxAmount) });
if (params.hasBalance === 'true') qb.andWhere('i.balance_amount > 0');
// Computed, not `status = OVERDUE` — nothing sweeps PENDING rows into it.
if (params.overdue === 'true') qb.andWhere('i.balance_amount > 0 AND i.due_at < now()');
if (params.companyId) qb.andWhere('i.company_id = :companyId', { companyId: params.companyId }); if (params.companyId) qb.andWhere('i.company_id = :companyId', { companyId: params.companyId });
if (params.search) { if (params.search) {
qb.andWhere('(i.invoice_number ILIKE :search OR c.name ILIKE :search)', { search: `%${params.search as string}%` }); qb.andWhere('(i.invoice_number ILIKE :search OR c.name ILIKE :search)', { search: `%${params.search as string}%` });

View File

@@ -1,8 +1,13 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common'; import { Body, Controller, Get, NotFoundException, Param, ParseUUIDPipe, Post, Query, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { BookingStaff } from '../../common/booking-guards'; import { BookingStaff, MixedAudience } from '../../common/booking-guards';
import { hasFreightPermission } from '../../common/freight-permission.util';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { BookingsService } from '../bookings/bookings.service';
import { import {
AssignCustomsRiskDto, AssignCustomsRiskDto,
CreateDjiboutiIncidentDto, CreateDjiboutiIncidentDto,
@@ -19,30 +24,38 @@ import { ImportOperationsService } from './import-operations.service';
@ApiBearerAuth() @ApiBearerAuth()
@Controller('import-operations') @Controller('import-operations')
// Post-booking customs / import-operations actions are GL/Ops work, mirroring the // Post-booking customs / import-operations actions are GL/Ops work, mirroring the
// contracts controller's GL operational endpoints (risk, duty, milestones). // contracts controller's GL operational endpoints (risk, duty, milestones). No
@BookingStaff(FREIGHT_PERMS.bookings.operations) // class-level guard: the equipment interchange receipt below is customer-reachable,
// every other route here stays staff-only via its own @BookingStaff.
export class ImportOperationsController { export class ImportOperationsController {
constructor(private readonly service: ImportOperationsService) {} constructor(
private readonly service: ImportOperationsService,
private readonly bookingsService: BookingsService,
) {}
@Get('djibouti-incidents') @Get('djibouti-incidents')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 8: list Djibouti import incidents' }) @ApiOperation({ summary: 'Batch 8: list Djibouti import incidents' })
listIncidents(@Query('bookingId') bookingId?: string) { listIncidents(@Query('bookingId') bookingId?: string) {
return this.service.listIncidents(bookingId); return this.service.listIncidents(bookingId);
} }
@Post('djibouti-incidents') @Post('djibouti-incidents')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 8: report a Djibouti import incident / exception' }) @ApiOperation({ summary: 'Batch 8: report a Djibouti import incident / exception' })
createIncident(@Body() dto: CreateDjiboutiIncidentDto) { createIncident(@Body() dto: CreateDjiboutiIncidentDto) {
return this.service.createIncident(dto); return this.service.createIncident(dto);
} }
@Get('customs/:bookingId') @Get('customs/:bookingId')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: import customs finalization state' }) @ApiOperation({ summary: 'Batch 12: import customs finalization state' })
getCustoms(@Param('bookingId', ParseUUIDPipe) bookingId: string) { getCustoms(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.service.getCustoms(bookingId); return this.service.getCustoms(bookingId);
} }
@Post('customs/:bookingId/documents') @Post('customs/:bookingId/documents')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: upload IM4/IM5/T1/permit/payment-slip documents' }) @ApiOperation({ summary: 'Batch 12: upload IM4/IM5/T1/permit/payment-slip documents' })
uploadCustomsDocument( uploadCustomsDocument(
@Param('bookingId', ParseUUIDPipe) bookingId: string, @Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -52,6 +65,7 @@ export class ImportOperationsController {
} }
@Post('customs/:bookingId/declaration') @Post('customs/:bookingId/declaration')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: record declaration serial number' }) @ApiOperation({ summary: 'Batch 12: record declaration serial number' })
recordDeclaration( recordDeclaration(
@Param('bookingId', ParseUUIDPipe) bookingId: string, @Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -61,6 +75,7 @@ export class ImportOperationsController {
} }
@Post('customs/:bookingId/notify-duties-taxes') @Post('customs/:bookingId/notify-duties-taxes')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: notify duties and taxes' }) @ApiOperation({ summary: 'Batch 12: notify duties and taxes' })
notifyDutiesTaxes( notifyDutiesTaxes(
@Param('bookingId', ParseUUIDPipe) bookingId: string, @Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -70,6 +85,7 @@ export class ImportOperationsController {
} }
@Post('customs/:bookingId/duties-taxes-paid') @Post('customs/:bookingId/duties-taxes-paid')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: mark duties and taxes paid' }) @ApiOperation({ summary: 'Batch 12: mark duties and taxes paid' })
markDutiesTaxesPaid( markDutiesTaxesPaid(
@Param('bookingId', ParseUUIDPipe) bookingId: string, @Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -79,12 +95,14 @@ export class ImportOperationsController {
} }
@Post('customs/:bookingId/risk') @Post('customs/:bookingId/risk')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: assign customs risk' }) @ApiOperation({ summary: 'Batch 12: assign customs risk' })
assignRisk(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: AssignCustomsRiskDto) { assignRisk(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: AssignCustomsRiskDto) {
return this.service.assignRisk(bookingId, dto); return this.service.assignRisk(bookingId, dto);
} }
@Post('customs/:bookingId/release-permitted') @Post('customs/:bookingId/release-permitted')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 12: mark import release permitted' }) @ApiOperation({ summary: 'Batch 12: mark import release permitted' })
markReleasePermitted( markReleasePermitted(
@Param('bookingId', ParseUUIDPipe) bookingId: string, @Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -94,18 +112,21 @@ export class ImportOperationsController {
} }
@Get('empty-container-returns') @Get('empty-container-returns')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 16: list empty container returns' }) @ApiOperation({ summary: 'Batch 16: list empty container returns' })
listEmptyReturns() { listEmptyReturns() {
return this.service.listEmptyReturns(); return this.service.listEmptyReturns();
} }
@Post('empty-container-returns') @Post('empty-container-returns')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 16: create an empty container return record' }) @ApiOperation({ summary: 'Batch 16: create an empty container return record' })
createEmptyReturn(@Body() dto: CreateEmptyContainerReturnDto) { createEmptyReturn(@Body() dto: CreateEmptyContainerReturnDto) {
return this.service.createEmptyReturn(dto); return this.service.createEmptyReturn(dto);
} }
@Post('empty-container-returns/load-on-train') @Post('empty-container-returns/load-on-train')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ @ApiOperation({
summary: 'Load returned empties onto an export train (1×40ft or 2×20ft per wagon)', summary: 'Load returned empties onto an export train (1×40ft or 2×20ft per wagon)',
}) })
@@ -114,6 +135,7 @@ export class ImportOperationsController {
} }
@Post('empty-container-returns/:id/status') @Post('empty-container-returns/:id/status')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Batch 16: advance empty container return workflow' }) @ApiOperation({ summary: 'Batch 16: advance empty container return workflow' })
updateEmptyReturnStatus( updateEmptyReturnStatus(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@@ -121,4 +143,53 @@ export class ImportOperationsController {
) { ) {
return this.service.updateEmptyReturnStatus(id, dto); return this.service.updateEmptyReturnStatus(id, dto);
} }
@Get('bookings/:bookingId/empty-container-returns')
@MixedAudience(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'List empty container returns for a booking (customer portal)' })
async listEmptyReturnsForBooking(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@CurrentUser() user: TCurrentUser,
) {
await this.assertCanAccessBooking(user, bookingId);
return this.service.listEmptyReturnsForBooking(bookingId);
}
@Get('empty-container-returns/:id/document')
@MixedAudience(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Download the equipment interchange receipt PDF (customer portal)' })
async equipmentInterchangeDocument(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
@Res() res: Response,
) {
const row = await this.service.getEmptyReturnOrThrow(id);
// A standalone (no-booking) return has no owner to check against, so it
// stays staff-only.
if (!row.bookingId) {
await this.assertCanAccessBooking(user, null);
} else {
await this.assertCanAccessBooking(user, row.bookingId);
}
const { filename, buffer } = await this.service.equipmentInterchangeDocument(row);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
return res.send(buffer);
}
/**
* Staff pass on permission alone. A customer must own the booking; `null`
* (a standalone, booking-less return) has no owner for a customer to match,
* so it 404s them the same way a foreign booking would.
*/
private async assertCanAccessBooking(user: TCurrentUser, bookingId: string | null): Promise<void> {
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.operations)) return;
if (!bookingId) {
throw new NotFoundException('Not found');
}
const booking = await this.bookingsService.findById(bookingId);
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
} }

View File

@@ -1,6 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module';
import { WarehousesModule } from '../warehouses/warehouses.module';
import { DjiboutiIncident } from './entities/djibouti-incident.entity'; import { DjiboutiIncident } from './entities/djibouti-incident.entity';
import { EmptyContainerReturn } from './entities/empty-container-return.entity'; import { EmptyContainerReturn } from './entities/empty-container-return.entity';
import { ImportCustomsFinalization } from './entities/import-customs-finalization.entity'; import { ImportCustomsFinalization } from './entities/import-customs-finalization.entity';
@@ -14,6 +16,11 @@ import { ImportOperationsService } from './import-operations.service';
ImportCustomsFinalization, ImportCustomsFinalization,
EmptyContainerReturn, EmptyContainerReturn,
]), ]),
// WarehouseReleaseDocumentService (the shared PDF renderer) for the
// equipment interchange receipt; BookingsModule for the customer
// ownership check on that same route.
WarehousesModule,
BookingsModule,
], ],
controllers: [ImportOperationsController], controllers: [ImportOperationsController],
providers: [ImportOperationsService], providers: [ImportOperationsService],

View File

@@ -2,6 +2,9 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm'; import { In, Repository } from 'typeorm';
import { LogoSettingsService } from '../logo-settings/logo-settings.service';
import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util';
import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service';
import { import {
CreateDjiboutiIncidentDto, CreateDjiboutiIncidentDto,
CreateEmptyContainerReturnDto, CreateEmptyContainerReturnDto,
@@ -39,6 +42,8 @@ export class ImportOperationsService {
private readonly customs: Repository<ImportCustomsFinalization>, private readonly customs: Repository<ImportCustomsFinalization>,
@InjectRepository(EmptyContainerReturn) @InjectRepository(EmptyContainerReturn)
private readonly emptyReturns: Repository<EmptyContainerReturn>, private readonly emptyReturns: Repository<EmptyContainerReturn>,
private readonly pdfDocuments: WarehouseReleaseDocumentService,
private readonly logoSettings: LogoSettingsService,
) {} ) {}
listIncidents(bookingId?: string) { listIncidents(bookingId?: string) {
@@ -150,6 +155,10 @@ export class ImportOperationsService {
return this.emptyReturns.find({ order: { createdAt: 'DESC' } as never }); return this.emptyReturns.find({ order: { createdAt: 'DESC' } as never });
} }
listEmptyReturnsForBooking(bookingId: string) {
return this.emptyReturns.find({ where: { bookingId }, order: { createdAt: 'DESC' } as never });
}
async createEmptyReturn(dto: CreateEmptyContainerReturnDto) { async createEmptyReturn(dto: CreateEmptyContainerReturnDto) {
const returnDate = dto.returnDate ? new Date(dto.returnDate) : new Date(); const returnDate = dto.returnDate ? new Date(dto.returnDate) : new Date();
return this.emptyReturns.save( return this.emptyReturns.save(
@@ -248,6 +257,142 @@ export class ImportOperationsService {
return this.emptyReturns.findOneOrFail({ where: { id } }); return this.emptyReturns.findOneOrFail({ where: { id } });
} }
async getEmptyReturnOrThrow(id: string): Promise<EmptyContainerReturn> {
const row = await this.emptyReturns.findOne({ where: { id } });
if (!row) {
throw new NotFoundException(`Empty container return ${id} not found`);
}
return row;
}
/**
* Equipment Interchange Receipt — container number/size, exact return
* timestamp, depot, condition, and the carrier/booking reference that ties
* the box back to its bill of lading. Handed to the customer to download.
*/
async equipmentInterchangeDocument(
row: EmptyContainerReturn,
): Promise<{ filename: string; buffer: Buffer }> {
const booking = row.bookingId
? ((
await this.emptyReturns.manager.query(
`SELECT b.reference, c.name AS company_name
FROM freight.bookings b
LEFT JOIN freight.companies c ON c.id = b.company_id
WHERE b.id = $1`,
[row.bookingId],
)
)[0] as { reference: string; company_name: string | null } | undefined)
: undefined;
const html = this.buildEquipmentInterchangeHtml(row, booking, {
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
});
const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Equipment interchange receipt');
return {
filename: `equipment-interchange-${row.containerNumber || row.id.slice(0, 8)}.pdf`,
buffer,
};
}
private buildEquipmentInterchangeHtml(
row: EmptyContainerReturn,
booking: { reference: string; company_name: string | null } | undefined,
opts: { logoImageUrl?: string | null },
): string {
const esc = (value: unknown) =>
String(value ?? '-')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const dateTime = (value: unknown) =>
value ? new Date(value as string | Date).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : '-';
const carrier =
row.returnedBy === 'EDR'
? 'EDR Last Mile'
: row.returnedBy === 'CUSTOMER'
? 'Customer Self-Haul'
: '-';
const rows: Array<[string, string]> = [
['Container Number', row.containerNumber],
['Container Size', row.containerSize ? `${row.containerSize}ft` : 'Not recorded'],
['Date & Time of Return', dateTime(row.returnDate)],
['Depot / Location', [row.facility, row.yard, row.zone].filter(Boolean).join(' — ') || '-'],
['Condition Status', row.condition || 'Good — no exceptions noted'],
['Carrier', carrier],
['Booking / BOL Reference', booking?.reference || 'Standalone — no booking'],
['Shipping Line / Customer', booking?.company_name || '-'],
['Current Status', row.status.replace(/_/g, ' ')],
['Handover Note', row.handoverNote || '-'],
];
const rowsHtml = rows
.map(
([label, value]) =>
`<tr><th>${esc(label)}</th><td>${esc(value)}</td></tr>`,
)
.join('');
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Equipment Interchange Receipt</title>
<style>
@page { size: A4; margin: 14mm; }
* { box-sizing: border-box; }
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
.top { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 3px solid #0f766e; padding-bottom: 12px; gap: 24px; }
.brand { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
h1 { margin: 6px 0 0; font-size: 22px; line-height: 1.1; }
.meta { text-align: right; font-size: 11px; color: #475569; }
.meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; }
${logoImageCss()}
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { border: 1px solid #cbd5e1; padding: 8px 10px; font-size: 11.5px; text-align: left; vertical-align: top; }
th { width: 220px; background: #f8fafc; color: #475569; font-weight: 700; }
.notice { margin-top: 16px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 10px 12px; font-size: 10.5px; color: #134e4a; }
.signatures { display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; margin-top: 40px; }
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 10px; color: #475569; min-height: 40px; }
</style>
</head>
<body>
<div class="top">
<div>
${logoMarkup(opts.logoImageUrl)}
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Equipment Interchange Receipt</h1>
</div>
<div class="meta">
Receipt No.
<strong>${esc(`EIR-${row.id.slice(0, 8).toUpperCase()}`)}</strong>
Generated: ${esc(new Date().toLocaleString('en-GB'))}
</div>
</div>
<table>
<tbody>
${rowsHtml}
</tbody>
</table>
<div class="notice">
This receipt confirms the physical interchange of the equipment described above at the
depot/location and time stated. Both parties should verify the container number, size,
and condition recorded here before signing.
</div>
<div class="signatures">
<div class="line">Depot officer name / signature / date</div>
<div class="line">Customer or driver name / signature / date</div>
</div>
</body>
</html>`;
}
private async getOrCreateCustoms(bookingId: string) { private async getOrCreateCustoms(bookingId: string) {
const existing = await this.customs.findOne({ where: { bookingId } }); const existing = await this.customs.findOne({ where: { bookingId } });
if (existing) return existing; if (existing) return existing;

View File

@@ -58,13 +58,18 @@ export class CreateOperationsTargetDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: description:
'Station targets only: which cargo category this station plan covers. Leave blank for the other dimensions.', 'Station targets only: which cargo category this station plan covers. Ignored for the ' +
'other dimensions, whose key already carries the category.',
example: 'CONTAINER_IMPORT_MULTIMODAL', example: 'CONTAINER_IMPORT_MULTIMODAL',
}) })
@IsOptional() @IsOptional()
// `'' ?? null` is `''`, and an empty string matches neither the unique
// index's `COALESCE(cargo_category, '')` nor the report's join — it reads as
// a category that does not exist. Blank means absent.
@Transform(({ value }) => (value === '' ? null : value))
@IsString() @IsString()
@MaxLength(60) @MaxLength(60)
cargoCategory?: string; cargoCategory?: string | null;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()

View File

@@ -1,8 +1,26 @@
import { BaseEntity } from '@edr/api-common'; import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm'; import { Column, Entity, Index } from 'typeorm';
/** Planning buckets the reports offer. Mirrors the reports' period filter. */ /**
export const TARGET_PERIOD_TYPES = ['week', 'month', 'quarter', 'year'] as const; * Planning buckets the reports offer. Mirrors the reports' period filter
* (`PERIOD_UNITS` in `reports/revenue-classification.ts`) — a planner must be
* able to commit a number at whatever grain the business quotes it, and the
* report then re-gathers it into whatever grain the viewer asks for.
*
* All eight anchor to the calendar year. `nine_month` and `ninety_day` are the
* two that do not divide it evenly: their last block of a year is short (OctDec
* and the 56 days after day 360). That is inherent to the unit, not a bug.
*/
export const TARGET_PERIOD_TYPES = [
'day',
'week',
'month',
'quarter',
'half_year',
'nine_month',
'ninety_day',
'year',
] as const;
export type TargetPeriodType = (typeof TARGET_PERIOD_TYPES)[number]; export type TargetPeriodType = (typeof TARGET_PERIOD_TYPES)[number];
/** What is being planned. */ /** What is being planned. */
@@ -31,9 +49,13 @@ export const TARGET_DIMENSION_LABELS: Record<TargetDimension, string> = {
}; };
export const TARGET_PERIOD_LABELS: Record<TargetPeriodType, string> = { export const TARGET_PERIOD_LABELS: Record<TargetPeriodType, string> = {
day: 'Daily',
week: 'Weekly', week: 'Weekly',
month: 'Monthly', month: 'Monthly',
quarter: 'Quarterly', quarter: 'Quarterly',
half_year: 'Half-yearly',
nine_month: 'Nine-monthly',
ninety_day: '90-day',
year: 'Yearly', year: 'Yearly',
}; };

View File

@@ -1,4 +1,4 @@
import { Global, Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { OperationsStandard } from './entities/operations-standard.entity'; import { OperationsStandard } from './entities/operations-standard.entity';
@@ -13,10 +13,11 @@ import { OperationsTargetsService } from './operations-targets.service';
* standards (one settings row) and the planned targets the reports compare * standards (one settings row) and the planned targets the reports compare
* actuals against. * actuals against.
* *
* Global because the reports module reads the standards row on every run and * Not global, and deliberately so: nothing outside this module injects either
* has no other reason to import this. * service. The reports read both tables in raw SQL — `STANDARDS_JOIN` and
* `plannedRowsSql` in `reports/operations-classification.ts` — so the exports
* below are for future callers, not current ones.
*/ */
@Global()
@Module({ @Module({
imports: [TypeOrmModule.forFeature([OperationsStandard, OperationsTarget])], imports: [TypeOrmModule.forFeature([OperationsStandard, OperationsTarget])],
controllers: [OperationsStandardsController, OperationsTargetsController], controllers: [OperationsStandardsController, OperationsTargetsController],

View File

@@ -0,0 +1,144 @@
import {
TARGET_PERIOD_LABELS,
TARGET_PERIOD_TYPES,
TargetPeriodType,
} from './entities/operations-target.entity';
import { normalisePeriodStart } from './operations-targets.service';
/**
* `normalisePeriodStart` decides which slot a target occupies — the unique
* index is keyed on its output — and it is one half of a pair. The other half
* is `PERIOD_UNITS[...].truncOn` in `reports/revenue-classification.ts`, which
* buckets the actuals. A target that snaps to a boundary the report does not
* bucket on is a plan measured against a period that does not exist, and
* nothing downstream would say so.
*
* Everything here is UTC on purpose: the column is a bare `date`, and the same
* arithmetic in local time shifts a 1st-of-month target into the previous month
* for anyone east of Greenwich.
*/
describe('normalisePeriodStart', () => {
it('leaves a daily target on its own day', () => {
expect(normalisePeriodStart('day', '2026-08-21')).toBe('2026-08-21');
});
it('snaps a week to its Monday', () => {
// 2026-08-21 is a Friday.
expect(normalisePeriodStart('week', '2026-08-21')).toBe('2026-08-17');
// A Sunday belongs to the week that started six days earlier, not the next.
expect(normalisePeriodStart('week', '2026-08-23')).toBe('2026-08-17');
expect(normalisePeriodStart('week', '2026-08-17')).toBe('2026-08-17');
});
it('snaps a month to the 1st', () => {
expect(normalisePeriodStart('month', '2026-08-21')).toBe('2026-08-01');
expect(normalisePeriodStart('month', '2026-08-01')).toBe('2026-08-01');
});
it('snaps a quarter to Jan/Apr/Jul/Oct', () => {
expect(normalisePeriodStart('quarter', '2026-02-14')).toBe('2026-01-01');
expect(normalisePeriodStart('quarter', '2026-05-01')).toBe('2026-04-01');
expect(normalisePeriodStart('quarter', '2026-08-21')).toBe('2026-07-01');
expect(normalisePeriodStart('quarter', '2026-12-31')).toBe('2026-10-01');
});
it('snaps a half-year to Jan/Jul', () => {
expect(normalisePeriodStart('half_year', '2026-01-01')).toBe('2026-01-01');
expect(normalisePeriodStart('half_year', '2026-06-30')).toBe('2026-01-01');
expect(normalisePeriodStart('half_year', '2026-07-01')).toBe('2026-07-01');
expect(normalisePeriodStart('half_year', '2026-12-31')).toBe('2026-07-01');
});
it('snaps a nine-month to Jan/Oct, leaving a short final block', () => {
expect(normalisePeriodStart('nine_month', '2026-01-01')).toBe('2026-01-01');
expect(normalisePeriodStart('nine_month', '2026-09-30')).toBe('2026-01-01');
// OctDec is three months, not nine. The block is short by design: nine
// does not divide twelve, and drifting out of the calendar year is worse.
expect(normalisePeriodStart('nine_month', '2026-10-01')).toBe('2026-10-01');
expect(normalisePeriodStart('nine_month', '2026-12-31')).toBe('2026-10-01');
});
it('snaps a 90-day block to day 1/91/181/271 of its year', () => {
expect(normalisePeriodStart('ninety_day', '2026-01-01')).toBe('2026-01-01');
expect(normalisePeriodStart('ninety_day', '2026-03-31')).toBe('2026-01-01'); // day 90
expect(normalisePeriodStart('ninety_day', '2026-04-01')).toBe('2026-04-01'); // day 91
expect(normalisePeriodStart('ninety_day', '2026-06-29')).toBe('2026-04-01'); // day 180
expect(normalisePeriodStart('ninety_day', '2026-06-30')).toBe('2026-06-30'); // day 181
expect(normalisePeriodStart('ninety_day', '2026-07-01')).toBe('2026-06-30');
expect(normalisePeriodStart('ninety_day', '2026-09-27')).toBe('2026-06-30'); // day 270
expect(normalisePeriodStart('ninety_day', '2026-09-28')).toBe('2026-09-28'); // day 271
});
it('widens the fourth 90-day block instead of opening a stub fifth', () => {
// Day 361 onwards would be its own block under an uncapped floor division —
// a five-day bucket at the end of every year. The cap keeps it in block 4,
// which must therefore match what late September resolves to.
const blockFour = normalisePeriodStart('ninety_day', '2026-09-28');
expect(normalisePeriodStart('ninety_day', '2026-12-27')).toBe(blockFour);
expect(normalisePeriodStart('ninety_day', '2026-12-31')).toBe(blockFour);
});
it('handles a leap year, where day 366 still lands in the fourth block', () => {
// 2028 is a leap year: Dec 31 is day 366.
expect(normalisePeriodStart('ninety_day', '2028-12-31')).toBe(
normalisePeriodStart('ninety_day', '2028-09-27'),
);
});
it('snaps a year to Jan 1', () => {
expect(normalisePeriodStart('year', '2026-08-21')).toBe('2026-01-01');
expect(normalisePeriodStart('year', '2026-01-01')).toBe('2026-01-01');
expect(normalisePeriodStart('year', '2026-12-31')).toBe('2026-01-01');
});
it('ignores any time component rather than letting it shift the day', () => {
expect(normalisePeriodStart('day', '2026-08-21T23:59:59.999Z')).toBe('2026-08-21');
expect(normalisePeriodStart('month', '2026-08-01T22:00:00+03:00')).toBe('2026-08-01');
});
it('is idempotent for every period type', () => {
// A normalised start must survive a second pass untouched, because `update`
// re-normalises whatever is already stored.
for (const periodType of TARGET_PERIOD_TYPES) {
for (const date of ['2026-01-01', '2026-05-17', '2026-08-21', '2026-12-31']) {
const once = normalisePeriodStart(periodType, date);
expect(normalisePeriodStart(periodType, once)).toBe(once);
}
}
});
it('never moves a date forward, only back to its block start', () => {
for (const periodType of TARGET_PERIOD_TYPES) {
for (const date of ['2026-02-28', '2026-06-15', '2026-10-02', '2026-12-31']) {
expect(normalisePeriodStart(periodType, date) <= date).toBe(true);
}
}
});
});
describe('target period vocabulary', () => {
it('labels every period type, so the admin grid shows no raw key', () => {
for (const periodType of TARGET_PERIOD_TYPES) {
expect(TARGET_PERIOD_LABELS[periodType]).toBeTruthy();
}
expect(Object.keys(TARGET_PERIOD_LABELS).sort()).toEqual([...TARGET_PERIOD_TYPES].sort());
});
it('keeps every period type inside the column width', () => {
// `period_type` is varchar(10); `nine_month` and `ninety_day` are exactly 10.
for (const periodType of TARGET_PERIOD_TYPES) {
expect(periodType.length).toBeLessThanOrEqual(10);
}
});
it('has a normalisation branch for every declared period type', () => {
// A type added to the union without a `case` would silently fall through
// and store an un-snapped date. Every type must move Dec 31 to a block
// start except `day`, which legitimately keeps it.
const unhandled = TARGET_PERIOD_TYPES.filter(
(t: TargetPeriodType) =>
t !== 'day' && normalisePeriodStart(t, '2026-12-31') === '2026-12-31',
);
expect(unhandled).toEqual([]);
});
});

View File

@@ -1,5 +1,10 @@
import { PaginatedResponse } from '@edr/types'; import { PaginatedResponse } from '@edr/types';
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Brackets, IsNull, Repository } from 'typeorm'; import { Brackets, IsNull, Repository } from 'typeorm';
@@ -12,6 +17,8 @@ import {
TARGET_DIMENSION_LABELS, TARGET_DIMENSION_LABELS,
TARGET_METRIC_LABELS, TARGET_METRIC_LABELS,
TARGET_PERIOD_LABELS, TARGET_PERIOD_LABELS,
TargetDimension,
TargetMetric,
TargetPeriodType, TargetPeriodType,
} from './entities/operations-target.entity'; } from './entities/operations-target.entity';
import { import {
@@ -19,10 +26,19 @@ import {
CONTAINER_CLASSES, CONTAINER_CLASSES,
} from '../reports/operations-classification'; } from '../reports/operations-classification';
const MS_PER_DAY = 86_400_000;
/** /**
* Normalises any date inside a bucket to the bucket's first day, matching * Normalises any date inside a bucket to the bucket's first day, matching the
* Postgres `date_trunc` — which is what the reports group by. Week starts * bucket expression the reports group by (`PERIOD_UNITS` in
* Monday, the same as `date_trunc('week', …)` and ISO week numbering. * `reports/revenue-classification.ts`). Week starts Monday, the same as
* `date_trunc('week', …)` and ISO week numbering.
*
* The four units Postgres has no `date_trunc` for are anchored to the calendar
* year, exactly as their SQL twins are: half-years at Jan/Jul, nine-months at
* Jan/Oct, ninety-days at day 1/91/181/271. **This function and
* `PERIOD_UNITS[...].truncOn` must agree** — a target whose `period_start` is
* not a real block start plans against a bucket boundary that does not exist.
* *
* Done in UTC throughout: the stored column is a bare `date`, and running the * Done in UTC throughout: the stored column is a bare `date`, and running the
* arithmetic in local time would shift a 1st-of-month target into the previous * arithmetic in local time would shift a 1st-of-month target into the previous
@@ -31,6 +47,8 @@ import {
export function normalisePeriodStart(periodType: TargetPeriodType, value: string): string { export function normalisePeriodStart(periodType: TargetPeriodType, value: string): string {
const d = new Date(`${value.slice(0, 10)}T00:00:00Z`); const d = new Date(`${value.slice(0, 10)}T00:00:00Z`);
switch (periodType) { switch (periodType) {
case 'day':
break;
case 'week': { case 'week': {
// getUTCDay(): 0 = Sunday. Monday-based offset puts Sunday six days in. // getUTCDay(): 0 = Sunday. Monday-based offset puts Sunday six days in.
const offset = (d.getUTCDay() + 6) % 7; const offset = (d.getUTCDay() + 6) % 7;
@@ -43,6 +61,22 @@ export function normalisePeriodStart(periodType: TargetPeriodType, value: string
case 'quarter': case 'quarter':
d.setUTCMonth(Math.floor(d.getUTCMonth() / 3) * 3, 1); d.setUTCMonth(Math.floor(d.getUTCMonth() / 3) * 3, 1);
break; break;
case 'half_year':
d.setUTCMonth(Math.floor(d.getUTCMonth() / 6) * 6, 1);
break;
case 'nine_month':
// Two blocks a year, not 1.33: JanSep, then a short OctDec.
d.setUTCMonth(Math.floor(d.getUTCMonth() / 9) * 9, 1);
break;
case 'ninety_day': {
// Day-of-year, zero-based, so this matches SQL's 1-based `(doy - 1) / 90`.
// Capped at block 3 for the same reason the SQL caps it: uncapped, the
// last days of December become a 5-day stub block of their own.
const yearStart = Date.UTC(d.getUTCFullYear(), 0, 1);
const dayIndex = Math.floor((d.getTime() - yearStart) / MS_PER_DAY);
d.setTime(yearStart + Math.min(Math.floor(dayIndex / 90), 3) * 90 * MS_PER_DAY);
break;
}
case 'year': case 'year':
d.setUTCMonth(0, 1); d.setUTCMonth(0, 1);
break; break;
@@ -76,6 +110,27 @@ const LABELS_BY_DIMENSION: Record<string, Map<string, string>> = {
const CARGO_CATEGORY_LABELS = LABELS_BY_DIMENSION.cargo_category; const CARGO_CATEGORY_LABELS = LABELS_BY_DIMENSION.cargo_category;
/**
* The keys a target may be stored against, per dimension. A report matches a
* target by this exact string, so a key outside the set here is a plan no
* report can ever find — and nothing downstream would ever say so. `station` is
* absent on purpose: yard codes are admin-managed rows, resolved live.
*
* `UNCLASSIFIED` is accepted for `cargo_category` even though the admin form
* does not offer it, because `CARGO_CATEGORY_EXPR` does emit it — rejecting a
* key the reports can match would be stricter than the reports themselves.
*/
const KEYS_BY_DIMENSION: Record<Exclude<TargetDimension, 'station'>, Set<string>> = {
cargo_category: new Set(CARGO_CATEGORIES.map((o) => o.value)),
container_class: new Set(CONTAINER_CLASSES.map((o) => o.value)),
};
/** The columns that decide which report row a target lines up with. */
type TargetSlot = Pick<
OperationsTarget,
'periodType' | 'periodStart' | 'metric' | 'dimension' | 'dimensionKey' | 'cargoCategory'
>;
@Injectable() @Injectable()
export class OperationsTargetsService { export class OperationsTargetsService {
constructor( constructor(
@@ -152,35 +207,113 @@ export class OperationsTargetsService {
} }
async create(dto: CreateOperationsTargetDto): Promise<OperationsTarget> { async create(dto: CreateOperationsTargetDto): Promise<OperationsTarget> {
const periodStart = normalisePeriodStart(dto.periodType, dto.periodStart); const slot = await this.resolveSlot(dto);
const cargoCategory = dto.cargoCategory ?? null; await this.assertSlotFree(slot);
await this.assertSlotFree({ ...dto, periodStart, cargoCategory }); return this.repository.save(this.repository.create({ ...dto, ...slot }));
return this.repository.save(this.repository.create({ ...dto, periodStart, cargoCategory }));
} }
async update(id: string, dto: UpdateOperationsTargetDto): Promise<OperationsTarget> { async update(id: string, dto: UpdateOperationsTargetDto): Promise<OperationsTarget> {
const current = await this.findById(id); const current = await this.findById(id);
const periodType = dto.periodType ?? current.periodType; const slot = await this.resolveSlot({
const periodStart = normalisePeriodStart(periodType, dto.periodStart ?? current.periodStart); periodType: dto.periodType ?? current.periodType,
const next = { periodStart: dto.periodStart ?? current.periodStart,
periodType,
periodStart,
metric: dto.metric ?? current.metric, metric: dto.metric ?? current.metric,
dimension: dto.dimension ?? current.dimension, dimension: dto.dimension ?? current.dimension,
dimensionKey: dto.dimensionKey ?? current.dimensionKey, dimensionKey: dto.dimensionKey ?? current.dimensionKey,
// An absent key means "unchanged" only while the dimension still wants a
// category at all — `resolveSlot` drops it when the dimension no longer
// does, which is the whole point of routing both paths through it.
cargoCategory: cargoCategory:
dto.cargoCategory !== undefined ? (dto.cargoCategory ?? null) : current.cargoCategory ?? null, dto.cargoCategory !== undefined ? dto.cargoCategory : current.cargoCategory,
}; });
await this.assertSlotFree(next, id); await this.assertSlotFree(slot, id);
await this.repository.update(id, { await this.repository.update(id, {
...next, ...slot,
...(dto.plannedValue != null ? { plannedValue: dto.plannedValue } : {}), ...(dto.plannedValue != null ? { plannedValue: dto.plannedValue } : {}),
...(dto.note !== undefined ? { note: dto.note } : {}), ...(dto.note !== undefined ? { note: dto.note } : {}),
}); });
return this.findById(id); return this.findById(id);
} }
/**
* Everything that decides which report row a target lines up with, resolved
* in one place so `create` and `update` cannot drift apart.
*
* `cargoCategory` is **derived from the dimension, never carried over**. A
* station's plan is per station AND per cargo type; the other two dimensions
* already carry the category in `dimensionKey`. A stale category left on a
* row whose dimension has moved on is not cosmetic — it survives the
* `COALESCE(cargo_category, '')` unique index alongside the legitimate
* null-category row, `plannedRowsSql` groups by it, and the two plan rows
* then both join the same operated row: the category lists twice, each time
* carrying the full operated tonnage, while the summary tiles stay correct.
*/
private async resolveSlot(input: {
periodType: TargetPeriodType;
periodStart: string;
metric: TargetMetric;
dimension: TargetDimension;
dimensionKey: string;
cargoCategory?: string | null;
}): Promise<TargetSlot> {
const periodStart = normalisePeriodStart(input.periodType, input.periodStart);
await this.assertDimensionKey(input.dimension, input.dimensionKey);
const base = {
periodType: input.periodType,
periodStart,
metric: input.metric,
dimension: input.dimension,
dimensionKey: input.dimensionKey,
};
if (input.dimension !== 'station') {
return { ...base, cargoCategory: null };
}
const cargoCategory = input.cargoCategory || null;
if (!cargoCategory) {
throw new BadRequestException(
'A station target needs a cargo category — the plan is per station and per cargo type. ' +
'Without one the report has nothing to match it against.',
);
}
if (!KEYS_BY_DIMENSION.cargo_category.has(cargoCategory)) {
throw new BadRequestException(
`"${cargoCategory}" is not a cargo category the reports produce. ` +
`Expected one of: ${[...KEYS_BY_DIMENSION.cargo_category].join(', ')}`,
);
}
return { ...base, cargoCategory };
}
/**
* A `dimensionKey` the reports never emit is a plan that silently never
* joins — the row lists fine and its label falls back to the raw key, so
* nothing downstream ever reports the mistake. Cheaper to reject on write.
*/
private async assertDimensionKey(dimension: TargetDimension, key: string): Promise<void> {
if (dimension === 'station') {
const yards = await this.yardLabels();
if (!yards.has(key)) {
throw new BadRequestException(
`"${key}" is not a known station code. A station target is keyed on ` +
'`yards.code`, which is what the reports match against.',
);
}
return;
}
const allowed = KEYS_BY_DIMENSION[dimension];
if (!allowed.has(key)) {
throw new BadRequestException(
`"${key}" is not a ${TARGET_DIMENSION_LABELS[dimension].toLowerCase()} the reports ` +
`produce. Expected one of: ${[...allowed].join(', ')}`,
);
}
}
async remove(id: string): Promise<void> { async remove(id: string): Promise<void> {
await this.findById(id); await this.findById(id);
await this.repository.softDelete(id); await this.repository.softDelete(id);

View File

@@ -0,0 +1,19 @@
import { ApiProperty } from '@nestjs/swagger';
import type { OverviewLayoutKey } from '../../../seed/freight-permissions.registry';
/**
* One entry per `GET /overview/layouts` item: a layout the caller holds the
* `edr_freight_app:overview:<key>:view` permission for. Mirrors the reports
* module's catalog entry (`ReportCatalogEntry`) — same "server filters by
* permission, frontend just renders what comes back" shape.
*/
export class OverviewLayoutDto {
@ApiProperty({
enum: ['clearance', 'occ', 'operation', 'marketer', 'finance', 'executive'],
})
key!: OverviewLayoutKey;
@ApiProperty()
label!: string;
}

View File

@@ -9,7 +9,13 @@ import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { BookingStaff } from '../../common/booking-guards'; import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { hasFreightPermission } from '../../common/freight-permission.util';
import {
FREIGHT_PERMS,
OVERVIEW_LAYOUT_KEYS,
OVERVIEW_LAYOUT_LABELS,
} from '../../seed/freight-permissions.registry';
import { OverviewLayoutDto } from './dto/overview-layout.dto';
import { OverviewQueryDto } from './dto/overview-query.dto'; import { OverviewQueryDto } from './dto/overview-query.dto';
import { OverviewResponseDto } from './dto/overview-response.dto'; import { OverviewResponseDto } from './dto/overview-response.dto';
import { import {
@@ -34,6 +40,22 @@ export class OverviewController {
private readonly userTradeAccessService: UserTradeAccessService, private readonly userTradeAccessService: UserTradeAccessService,
) {} ) {}
/**
* Layouts the caller has permission to render, in priority order — exactly
* the same "server filters by permission, frontend just renders what comes
* back" shape as GET /reports. A caller lands on exactly one layout, so the
* frontend picks the first entry here rather than rendering the whole list.
*/
@Get('layouts')
@BookingStaff(FREIGHT_PERMS.overview.view)
@ApiOperation({ summary: 'Overview dashboard layouts the caller has permission to render' })
@ApiOkResponse({ type: OverviewLayoutDto, isArray: true })
getLayouts(@CurrentUser() user: TCurrentUser): OverviewLayoutDto[] {
return OVERVIEW_LAYOUT_KEYS.filter((key) =>
hasFreightPermission(user, FREIGHT_PERMS.overview.layout(key)),
).map((key) => ({ key, label: OVERVIEW_LAYOUT_LABELS[key] }));
}
@Get() @Get()
@BookingStaff(FREIGHT_PERMS.overview.view) @BookingStaff(FREIGHT_PERMS.overview.view)
@ApiOperation({ summary: 'Aggregated dashboard summary for backoffice overview' }) @ApiOperation({ summary: 'Aggregated dashboard summary for backoffice overview' })

View File

@@ -1,129 +0,0 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { Company } from '../../companies/entities/company.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// For PER_ITEM bulk bookings cargo_total_weight_vgm holds an item COUNT, and
// the real tonnage lives in bulk_total_weight_tons — hence the COALESCE order
// (same guard as the retired report-queries.ts).
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
// adjusted_total_amount silently overrides total_amount when set.
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
// GENERAL contract_kind rows are umbrella contracts, not shipments; counting
// them double-counts every child booking.
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
function applyFilters(
ctx: ReportContext,
qb: SelectQueryBuilder<ObjectLiteral>,
): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
qb.where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`);
if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo });
if (params.direction) qb.andWhere('b.trade_direction = :direction', { direction: params.direction });
if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType });
const statuses = params.statuses as string[] | null;
if (statuses) {
qb.andWhere('b.status IN (:...statuses)', { statuses });
} else {
qb.andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES });
}
if (params.search) {
qb.andWhere('(b.reference ILIKE :search OR c.name ILIKE :search)', {
search: `%${params.search}%`,
});
}
if (directions !== null) {
qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', {
directions,
});
}
return qb;
}
export const bookingsListReport: ReportDefinition = {
key: 'bookings-list',
title: 'Bookings',
description: 'Every booking with customer, route, cargo and revenue',
group: 'Commercial',
filters: [
{ key: 'date', label: 'Created', type: 'daterange' },
{
key: 'direction',
label: 'Direction',
type: 'select',
options: [
{ value: 'IMPORT', label: 'Import' },
{ value: 'EXPORT', label: 'Export' },
{ value: 'DOMESTIC', label: 'Domestic' },
],
},
{
key: 'freightType',
label: 'Freight type',
type: 'select',
options: [
{ value: 'CONTAINER', label: 'Container' },
{ value: 'BULK', label: 'Bulk' },
],
},
{ key: 'statuses', label: 'Status', type: 'multiselect' },
{ key: 'search', label: 'Search reference or customer', type: 'text' },
],
columns: [
{ key: 'reference', label: 'Reference', type: 'string', sortable: true, sortExpr: 'b.reference' },
{ key: 'created', label: 'Created', type: 'date', sortable: true, sortExpr: 'b.created_at' },
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'b.status' },
{ key: 'direction', label: 'Direction', type: 'string' },
{ key: 'origin', label: 'Origin', type: 'string' },
{ key: 'destination', label: 'Destination', type: 'string' },
{ key: 'cargo', label: 'Cargo', type: 'string' },
{ key: 'tons', label: 'Tonnage', type: 'tons', sortable: true },
{ key: 'amount', label: 'Amount', type: 'money', sortable: true },
],
defaultSort: { key: 'created', dir: 'DESC' },
query(ctx) {
const qb = ctx.ds
.createQueryBuilder()
.select('b.reference', 'reference')
.addSelect(`to_char(b.created_at, 'YYYY-MM-DD')`, 'created')
.addSelect('c.name', 'customer')
.addSelect('b.status', 'status')
.addSelect('b.trade_direction', 'direction')
.addSelect('o.label', 'origin')
.addSelect('d.label', 'destination')
.addSelect('COALESCE(cty.cargo_type_name, b.cargo_free_text)', 'cargo')
.addSelect(`ROUND(${TONS})::float8`, 'tons')
.addSelect(`ROUND(${REVENUE})::float8`, 'amount')
.from(Booking, 'b')
.innerJoin(Company, 'c', 'c.id = b.company_id')
.innerJoin(Yard, 'o', 'o.id = b.origin_yard_id')
.innerJoin(Yard, 'd', 'd.id = b.destination_yard_id')
.leftJoin(CargoType, 'cty', 'cty.id = b.cargo_type_id');
return applyFilters(ctx, qb);
},
async summary(ctx) {
const qb = applyFilters(
ctx,
ctx.ds
.createQueryBuilder()
.select('COUNT(*)::int', 'bookings')
.addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons')
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue')
.from(Booking, 'b')
.innerJoin(Company, 'c', 'c.id = b.company_id'),
);
const row = await qb.getRawOne();
return [
{ label: 'Bookings', value: Number(row?.bookings ?? 0) },
{ label: 'Tonnage', value: Number(row?.tons ?? 0), unit: 't' },
{ label: 'Revenue', value: Number(row?.revenue ?? 0), unit: 'ETB' },
];
},
};

View File

@@ -13,6 +13,7 @@ import {
TEU_EXPR, TEU_EXPR,
allocationLedgerQb, allocationLedgerQb,
applyCategoryFilter, applyCategoryFilter,
attainmentCtx,
PLAN_GRANULARITY_NOTE, PLAN_GRANULARITY_NOTE,
implementRateExpr, implementRateExpr,
plannedRowsParams, plannedRowsParams,
@@ -86,6 +87,7 @@ export const cargoVolumeByStationReport: ReportDefinition = {
{ key: 'category', label: 'Cargo type', type: 'string', sortable: true }, { key: 'category', label: 'Cargo type', type: 'string', sortable: true },
{ key: 'operated', label: 'Operated', type: 'tons', sortable: true }, { key: 'operated', label: 'Operated', type: 'tons', sortable: true },
{ key: 'plan', label: 'Plan', type: 'tons' }, { key: 'plan', label: 'Plan', type: 'tons' },
{ key: 'planRequired', label: 'Required', type: 'tons' },
{ key: 'implementRate', label: 'Implement rate', type: 'percent' }, { key: 'implementRate', label: 'Implement rate', type: 'percent' },
{ key: 'teu', label: 'TEU', type: 'number' }, { key: 'teu', label: 'TEU', type: 'number' },
{ key: 'wagons', label: 'Wagons', type: 'number' }, { key: 'wagons', label: 'Wagons', type: 'number' },
@@ -118,6 +120,18 @@ export const cargoVolumeByStationReport: ReportDefinition = {
.addGroupBy(originationExpr(params, 'code')) .addGroupBy(originationExpr(params, 'code'))
.addGroupBy(CARGO_CATEGORY_EXPR); .addGroupBy(CARGO_CATEGORY_EXPR);
// Attainment for the cascade, keyed the way a station plan is: per station
// AND per cargo type. Unfiltered by date, so a mid-year view still knows
// what the station has already hauled against its target.
const attained = baseQuery(attainmentCtx(ctx))
.select(periodTruncExprOn(OPS_DATE, params), 'bucket')
.addSelect(stationCode, 'act_key')
.addSelect(CARGO_CATEGORY_EXPR, 'act_category')
.addSelect(`${ACTUAL_TONS_EXPR}`, 'actual')
.groupBy(periodTruncExprOn(OPS_DATE, params))
.addGroupBy(stationCode)
.addGroupBy(CARGO_CATEGORY_EXPR);
// A station plan is keyed on station AND cargo type, so the join needs // A station plan is keyed on station AND cargo type, so the join needs
// both. Full outer, so a station-and-cargo line that was planned and never // both. Full outer, so a station-and-cargo line that was planned and never
// ran still reports its miss — the OCC report is full of those. // ran still reports its miss — the OCC report is full of those.
@@ -134,9 +148,15 @@ export const cargoVolumeByStationReport: ReportDefinition = {
COALESCE(o.teu, 0) AS teu, COALESCE(o.teu, 0) AS teu,
COALESCE(o.wagons, 0) AS wagons, COALESCE(o.wagons, 0) AS wagons,
COALESCE(o.trains, 0) AS trains, COALESCE(o.trains, 0) AS trains,
p.plan_value AS plan p.plan_value AS plan,
p.plan_required AS plan_required
FROM (${operated.getQuery()}) o FROM (${operated.getQuery()}) o
FULL OUTER JOIN (${plannedRowsSql('VOLUME_TONS', 'station', params)}) p FULL OUTER JOIN (${plannedRowsSql(
'VOLUME_TONS',
'station',
params,
attained.getQuery(),
)}) p
ON p.period = o.period ON p.period = o.period
AND p.plan_key = o.station_code AND p.plan_key = o.station_code
AND p.plan_category = o.category_key`; AND p.plan_category = o.category_key`;
@@ -144,7 +164,11 @@ export const cargoVolumeByStationReport: ReportDefinition = {
return ctx.ds return ctx.ds
.createQueryBuilder() .createQueryBuilder()
.from(`(${combined})`, 'r') .from(`(${combined})`, 'r')
.setParameters({ ...operated.getParameters(), ...plannedRowsParams(params) }) .setParameters({
...operated.getParameters(),
...attained.getParameters(),
...plannedRowsParams(params),
})
.select('r.period', 'period') .select('r.period', 'period')
.addSelect('r.station', 'station') .addSelect('r.station', 'station')
.addSelect('r.origination', 'origination') .addSelect('r.origination', 'origination')
@@ -152,6 +176,7 @@ export const cargoVolumeByStationReport: ReportDefinition = {
.addSelect('r.category_key', 'categoryKey') .addSelect('r.category_key', 'categoryKey')
.addSelect('r.operated::float8', 'operated') .addSelect('r.operated::float8', 'operated')
.addSelect('r.plan::float8', 'plan') .addSelect('r.plan::float8', 'plan')
.addSelect('r.plan_required::float8', 'planRequired')
.addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate') .addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate')
.addSelect('r.teu::int', 'teu') .addSelect('r.teu::int', 'teu')
.addSelect('r.wagons::int', 'wagons') .addSelect('r.wagons::int', 'wagons')

View File

@@ -13,6 +13,7 @@ import {
TEU_EXPR, TEU_EXPR,
allocationLedgerQb, allocationLedgerQb,
applyCategoryFilter, applyCategoryFilter,
attainmentCtx,
PLAN_GRANULARITY_NOTE, PLAN_GRANULARITY_NOTE,
implementRateExpr, implementRateExpr,
plannedRowsParams, plannedRowsParams,
@@ -42,6 +43,7 @@ export const cargoVolumePerformanceReport: ReportDefinition = {
{ key: 'category', label: 'Cargo category', type: 'string', sortable: true }, { key: 'category', label: 'Cargo category', type: 'string', sortable: true },
{ key: 'operated', label: 'Operated', type: 'tons', sortable: true }, { key: 'operated', label: 'Operated', type: 'tons', sortable: true },
{ key: 'plan', label: 'Plan', type: 'tons' }, { key: 'plan', label: 'Plan', type: 'tons' },
{ key: 'planRequired', label: 'Required', type: 'tons' },
{ key: 'implementRate', label: 'Implement rate', type: 'percent' }, { key: 'implementRate', label: 'Implement rate', type: 'percent' },
{ key: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true }, { key: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true },
{ key: 'teu', label: 'TEU', type: 'number', sortable: true }, { key: 'teu', label: 'TEU', type: 'number', sortable: true },
@@ -63,6 +65,17 @@ export const cargoVolumePerformanceReport: ReportDefinition = {
.groupBy(bucket) .groupBy(bucket)
.addGroupBy(CARGO_CATEGORY_EXPR); .addGroupBy(CARGO_CATEGORY_EXPR);
// What the cascade measures attainment from: the same tonnage, over the
// target's whole period rather than the user's date window. Bucketed on the
// block start, not the label, so it joins the plan on a real timestamp.
const attained = baseQuery(attainmentCtx(ctx))
.select(periodTruncExprOn(OPS_DATE, ctx.params), 'bucket')
.addSelect(CARGO_CATEGORY_EXPR, 'act_key')
.addSelect('NULL::varchar', 'act_category')
.addSelect(`${ACTUAL_TONS_EXPR}`, 'actual')
.groupBy(periodTruncExprOn(OPS_DATE, ctx.params))
.addGroupBy(CARGO_CATEGORY_EXPR);
// Full outer join so a planned cargo category that moved nothing still // Full outer join so a planned cargo category that moved nothing still
// reports its miss instead of disappearing from the table. // reports its miss instead of disappearing from the table.
const combined = ` const combined = `
@@ -73,20 +86,31 @@ export const cargoVolumePerformanceReport: ReportDefinition = {
COALESCE(o.teu, 0) AS teu, COALESCE(o.teu, 0) AS teu,
COALESCE(o.wagons, 0) AS wagons, COALESCE(o.wagons, 0) AS wagons,
COALESCE(o.trains, 0) AS trains, COALESCE(o.trains, 0) AS trains,
p.plan_value AS plan p.plan_value AS plan,
p.plan_required AS plan_required
FROM (${operated.getQuery()}) o FROM (${operated.getQuery()}) o
FULL OUTER JOIN (${plannedRowsSql('VOLUME_TONS', 'cargo_category', ctx.params)}) p FULL OUTER JOIN (${plannedRowsSql(
'VOLUME_TONS',
'cargo_category',
ctx.params,
attained.getQuery(),
)}) p
ON p.period = o.period AND p.plan_key = o.category_key`; ON p.period = o.period AND p.plan_key = o.category_key`;
return ctx.ds return ctx.ds
.createQueryBuilder() .createQueryBuilder()
.from(`(${combined})`, 'r') .from(`(${combined})`, 'r')
.setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) }) .setParameters({
...operated.getParameters(),
...attained.getParameters(),
...plannedRowsParams(ctx.params),
})
.select('r.period', 'period') .select('r.period', 'period')
.addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category') .addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category')
.addSelect('r.category_key', 'categoryKey') .addSelect('r.category_key', 'categoryKey')
.addSelect('r.operated::float8', 'operated') .addSelect('r.operated::float8', 'operated')
.addSelect('r.plan::float8', 'plan') .addSelect('r.plan::float8', 'plan')
.addSelect('r.plan_required::float8', 'planRequired')
.addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate') .addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate')
.addSelect('r.charged_tons::float8', 'chargedTons') .addSelect('r.charged_tons::float8', 'chargedTons')
.addSelect('r.teu::int', 'teu') .addSelect('r.teu::int', 'teu')

View File

@@ -1,83 +0,0 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Contract, CONTRACT_KINDS, CONTRACT_STATUSES } from '../../contracts/entities/contract.entity';
import { Company } from '../../companies/entities/company.entity';
import { ReportContext, ReportDefinition } from '../report.types';
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Contract, 'ct')
.leftJoin(Company, 'c', 'c.id = ct.company_id')
.where('ct.deleted_at IS NULL');
if (params.dateFrom) qb.andWhere('ct.contract_valid_from >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('ct.contract_valid_from < :dateTo', { dateTo: params.dateTo });
if (params.kind) qb.andWhere('ct.contract_kind = :kind', { kind: params.kind });
if (params.direction) qb.andWhere('ct.trade_direction = :direction', { direction: params.direction });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('ct.status IN (:...statuses)', { statuses });
if (directions !== null) {
qb.andWhere(directions.length ? 'ct.trade_direction IN (:...directions)' : '1 = 0', { directions });
}
return qb;
}
export const contractLifecycleReport: ReportDefinition = {
key: 'contract-lifecycle',
title: 'Contracts',
description: 'Signed, active and cancelled contracts',
group: 'Commercial',
filters: [
{ key: 'date', label: 'Valid from', type: 'daterange' },
{ key: 'kind', label: 'Kind', type: 'select', options: CONTRACT_KINDS.map((v) => ({ value: v, label: v })) },
{
key: 'direction',
label: 'Direction',
type: 'select',
options: [
{ value: 'IMPORT', label: 'Import' },
{ value: 'EXPORT', label: 'Export' },
{ value: 'DOMESTIC', label: 'Domestic' },
],
},
{ key: 'statuses', label: 'Status', type: 'multiselect', options: CONTRACT_STATUSES.map((v) => ({ value: v, label: v.replace(/_/g, ' ') })) },
],
columns: [
{ key: 'reference', label: 'Reference', type: 'string', sortable: true, sortExpr: 'ct.reference' },
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
{ key: 'kind', label: 'Kind', type: 'string' },
{ key: 'direction', label: 'Direction', type: 'string' },
{ key: 'freightType', label: 'Freight type', type: 'string' },
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'ct.status' },
{ key: 'validFrom', label: 'Valid from', type: 'date', sortable: true, sortExpr: 'ct.contract_valid_from' },
{ key: 'validUntil', label: 'Valid until', type: 'date' },
{ key: 'signedAt', label: 'Signed', type: 'date' },
],
defaultSort: { key: 'validFrom', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('ct.reference', 'reference')
.addSelect("COALESCE(c.name, ct.government_institution, 'Unknown')", 'customer')
.addSelect('ct.contract_kind', 'kind')
.addSelect('ct.trade_direction', 'direction')
.addSelect('ct.freight_type', 'freightType')
.addSelect('ct.status', 'status')
.addSelect(`to_char(ct.contract_valid_from, 'YYYY-MM-DD')`, 'validFrom')
.addSelect(`to_char(ct.contract_valid_until, 'YYYY-MM-DD')`, 'validUntil')
.addSelect(`to_char(ct.fully_executed_at, 'YYYY-MM-DD')`, 'signedAt');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'total')
.addSelect('COUNT(*) FILTER (WHERE ct.fully_executed_at IS NOT NULL)::int', 'signed')
.addSelect("COUNT(*) FILTER (WHERE ct.status = 'CANCELLED')::int", 'cancelled')
.getRawOne();
return [
{ label: 'Contracts', value: Number(row?.total ?? 0) },
{ label: 'Signed', value: Number(row?.signed ?? 0) },
{ label: 'Cancelled', value: Number(row?.cancelled ?? 0) },
];
},
};

View File

@@ -1,67 +0,0 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { CompanyProfile, ProfileStatus, ProfileType } from '../../companies/entities/company-profile.entity';
import { Company } from '../../companies/entities/company.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// "Type (Importer, Exporter, Freight Forwarding)" and "Active/Suspended" are
// CompanyProfile fields, not Company's — a company can hold several profiles
// (e.g. importer AND exporter), each independently approved/suspended.
const TYPE_OPTIONS = Object.values(ProfileType).map((v) => ({ value: v, label: v.replace(/_/g, ' ') }));
const STATUS_OPTIONS = Object.values(ProfileStatus).map((v) => ({ value: v, label: v }));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(CompanyProfile, 'cp')
.innerJoin(Company, 'c', 'c.id = cp.company_id')
.where('cp.deleted_at IS NULL');
if (params.type) qb.andWhere('cp.type = :type', { type: params.type });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('cp.status IN (:...statuses)', { statuses });
return qb;
}
export const customerStatusReport: ReportDefinition = {
key: 'customer-status',
title: 'Customer Profiles',
description: 'Company profiles by role type and approval status',
group: 'Commercial',
filters: [
{ key: 'type', label: 'Type', type: 'select', options: TYPE_OPTIONS },
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
],
columns: [
{ key: 'company', label: 'Company', type: 'string', sortable: true, sortExpr: 'c.name' },
{ key: 'type', label: 'Type', type: 'string', sortable: true, sortExpr: 'cp.type' },
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'cp.status' },
{ key: 'reference', label: 'Reference', type: 'string' },
{ key: 'note', label: 'Note', type: 'string' },
{ key: 'reviewedAt', label: 'Reviewed', type: 'date', sortable: true, sortExpr: 'cp.reviewed_at' },
],
defaultSort: { key: 'reviewedAt', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('c.name', 'company')
.addSelect('cp.type', 'type')
.addSelect('cp.status', 'status')
.addSelect("COALESCE(cp.reference, '')", 'reference')
.addSelect("COALESCE(cp.review_note, '')", 'note')
.addSelect(`to_char(cp.reviewed_at, 'YYYY-MM-DD')`, 'reviewedAt');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'total')
.addSelect('COUNT(*) FILTER (WHERE cp.status = :active)::int', 'active')
.addSelect('COUNT(*) FILTER (WHERE cp.status = :suspended)::int', 'suspended')
.setParameters({ active: ProfileStatus.Active, suspended: ProfileStatus.Suspended })
.getRawOne();
return [
{ label: 'Profiles', value: Number(row?.total ?? 0) },
{ label: 'Active', value: Number(row?.active ?? 0) },
{ label: 'Suspended', value: Number(row?.suspended ?? 0) },
];
},
};

View File

@@ -1,72 +0,0 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Freight } from '@edr/types';
import { Invoice } from '../../billing/entities/invoice.entity';
import { Company } from '../../companies/entities/company.entity';
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
import { ReportContext, ReportDefinition } from '../report.types';
const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((v) => ({ value: v, label: v }));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Invoice, 'i')
.innerJoin(Company, 'c', 'c.id = i.company_id')
.leftJoin(CompanyProfile, 'cp', 'cp.id = i.company_profile_id')
.where('i.deleted_at IS NULL');
if (params.dateFrom) qb.andWhere('i.issued_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('i.issued_at < :dateTo', { dateTo: params.dateTo });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('i.status IN (:...statuses)', { statuses });
return qb;
}
export const invoicesByStatusReport: ReportDefinition = {
key: 'invoices-by-status',
title: 'Invoices',
description: 'Every invoice with customer, profile type and settlement status',
group: 'Finance',
filters: [
{ key: 'date', label: 'Issued', type: 'daterange' },
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
],
columns: [
{ key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' },
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
{ key: 'profileType', label: 'Profile', type: 'string' },
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'i.status' },
{ key: 'totalAmount', label: 'Total', type: 'money', sortable: true },
{ key: 'paidAmount', label: 'Paid', type: 'money' },
{ key: 'balanceAmount', label: 'Balance', type: 'money', sortable: true },
{ key: 'issuedAt', label: 'Issued', type: 'date', sortable: true, sortExpr: 'i.issued_at' },
{ key: 'dueAt', label: 'Due', type: 'date' },
],
defaultSort: { key: 'issuedAt', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('i.invoice_number', 'invoiceNumber')
.addSelect('c.name', 'customer')
.addSelect("COALESCE(cp.type, 'Unknown')", 'profileType')
.addSelect('i.status', 'status')
.addSelect('ROUND(i.total_amount)::float8', 'totalAmount')
.addSelect('ROUND(i.paid_amount)::float8', 'paidAmount')
.addSelect('ROUND(i.balance_amount)::float8', 'balanceAmount')
.addSelect(`to_char(i.issued_at, 'YYYY-MM-DD')`, 'issuedAt')
.addSelect(`to_char(i.due_at, 'YYYY-MM-DD')`, 'dueAt');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'invoices')
.addSelect('ROUND(COALESCE(SUM(i.total_amount), 0))::float8', 'total')
.addSelect('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'balance')
.getRawOne();
return [
{ label: 'Invoices', value: Number(row?.invoices ?? 0) },
{ label: 'Total value', value: Number(row?.total ?? 0), unit: 'ETB' },
{ label: 'Outstanding', value: Number(row?.balance ?? 0), unit: 'ETB' },
];
},
};

View File

@@ -1,73 +0,0 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { PaymentEntity } from '../../payment/entities/payment.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// No direct company link on payments (refId points at whatever the intent was
// for — booking, demurrage, ...); breakdown stops at status/method/currency.
const STATUS_OPTIONS = [
{ value: 'action-required', label: 'Action required' },
{ value: 'processing', label: 'Processing' },
{ value: 'success', label: 'Success' },
{ value: 'failed', label: 'Failed' },
{ value: 'canceled', label: 'Canceled' },
{ value: 'refunded', label: 'Refunded' },
];
const METHOD_OPTIONS = ['telebirr', 'cbe-birr', 'ebirr', 'waafi', 'card', 'dmoney', 'cac-bank', 'cbe-bill'].map(
(v) => ({ value: v, label: v }),
);
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
// payments carries no deleted_at column (unlike the rest of the schema) —
// confirmed against the live DB, not assumed from BaseEntity.
const qb = ctx.ds.createQueryBuilder().from(PaymentEntity, 'p').where('1 = 1');
if (params.dateFrom) qb.andWhere('p.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('p.created_at < :dateTo', { dateTo: params.dateTo });
if (params.method) qb.andWhere('p.method = :method', { method: params.method });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('p.status IN (:...statuses)', { statuses });
return qb;
}
export const paymentsByStatusReport: ReportDefinition = {
key: 'payments-by-status',
title: 'Payments by Status',
description: 'Payment volume and value by status, method and currency',
group: 'Finance',
filters: [
{ key: 'date', label: 'Created', type: 'daterange' },
{ key: 'method', label: 'Method', type: 'select', options: METHOD_OPTIONS },
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
],
columns: [
{ key: 'status', label: 'Status', type: 'string', sortable: true },
{ key: 'method', label: 'Method', type: 'string', sortable: true },
{ key: 'currency', label: 'Currency', type: 'string' },
{ key: 'payments', label: 'Payments', type: 'number', sortable: true },
{ key: 'amount', label: 'Amount', type: 'money', sortable: true },
],
defaultSort: { key: 'amount', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('p.status', 'status')
.addSelect('p.method', 'method')
.addSelect('p.currency', 'currency')
.addSelect('COUNT(*)::int', 'payments')
.addSelect('ROUND(COALESCE(SUM(p.amount), 0))::float8', 'amount')
.groupBy('p.status')
.addGroupBy('p.method')
.addGroupBy('p.currency');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'payments')
.addSelect("ROUND(COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'success'), 0))::float8", 'paid')
.getRawOne();
return [
{ label: 'Payments', value: Number(row?.payments ?? 0) },
{ label: 'Total paid', value: Number(row?.paid ?? 0), unit: 'ETB' },
];
},
};

View File

@@ -0,0 +1,71 @@
import { WAGON_CANCELLATION_STATUSES } from '../../bookings/entities/booking-wagon-cancellation.entity';
import { ShippingLineCreditStatus } from '../../shipping-lines/entities/shipping-line-credit.entity';
import {
CREDIT_LIABILITY_STATUS,
INVOICE_SIDE_EXPR,
LEDGER_SIDES,
UNINVOICED_CREDIT_STATUS,
receivablesPayablesReport,
} from './receivables-payables.report';
/**
* The report's whole point is the sign of the money: a cancellation FEE is
* owed TO EDR, and the cancelled freight is owed BACK to the customer as
* bookable credit. These tests pin the two down at the string level — the SQL
* itself is validated against the database, not here.
*/
describe('receivables-payables report', () => {
it('treats exactly one wagon-cancellation status as a liability', () => {
expect(WAGON_CANCELLATION_STATUSES).toContain(CREDIT_LIABILITY_STATUS);
// Every other status owes nothing: nothing cut yet (FEE_PENDING), redeemed
// (REBOOKED), or voided (WITHDRAWN / EXPIRED). If a new status appears,
// this fails until someone decides which side of the ledger it lands on.
expect(WAGON_CANCELLATION_STATUSES.filter((s) => s !== CREDIT_LIABILITY_STATUS).sort()).toEqual(
['EXPIRED', 'FEE_PENDING', 'REBOOKED', 'WITHDRAWN'],
);
});
it('counts only the shipping-line credit status that has no invoice behind it', () => {
expect(UNINVOICED_CREDIT_STATUS).toBe(ShippingLineCreditStatus.Unbilled);
// BILLED is debt too, but it is counted through its invoice on the invoice
// branch — taking it here as well would double it.
expect(UNINVOICED_CREDIT_STATUS).not.toBe(ShippingLineCreditStatus.Billed);
});
it('never classifies the cancellation fee as a payable', () => {
// The fee invoice rides the booking's invoice list; while it is open it is
// an ordinary receivable balance, and it must not reach a PAYABLE arm.
expect(INVOICE_SIDE_EXPR).not.toContain('WAGON_CANCEL_FEE');
expect(INVOICE_SIDE_EXPR).not.toContain('CANCELLATION_FEE');
});
it('does not double-count a booking already carried by the cancellation ledger', () => {
expect(INVOICE_SIDE_EXPR).toContain('NOT EXISTS');
expect(INVOICE_SIDE_EXPR).toContain('booking_wagon_cancellations');
});
it('emits exactly the side keys the filter offers', () => {
const declared = LEDGER_SIDES.map((s) => s.value).sort();
expect(declared).toEqual([
'PAYABLE_PREPAID',
'PAYABLE_WAGON_CREDIT',
'RECEIVABLE_OPEN',
'RECEIVABLE_SL_INVOICED',
'RECEIVABLE_SL_UNBILLED',
]);
// The summary KPIs split on these prefixes; a key matching neither would
// silently vanish from both totals.
for (const key of declared) {
expect(key.startsWith('RECEIVABLE') || key.startsWith('PAYABLE')).toBe(true);
}
});
it('sorts on the union wrapper, never on a branch-local alias', () => {
// The runner appends ORDER BY outside the union subquery, where `i.*`,
// `b.*` and `bwc.*` do not exist.
for (const col of receivablesPayablesReport.columns) {
if (!col.sortExpr) continue;
expect(col.sortExpr).toMatch(/^r\./);
}
});
});

View File

@@ -1,5 +1,12 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { BookingWagonCancellation } from '../../bookings/entities/booking-wagon-cancellation.entity';
import { Company } from '../../companies/entities/company.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
import { ShippingLineCredit } from '../../shipping-lines/entities/shipping-line-credit.entity';
import { directionScopeSql } from '../../user-trade-access/trade-scope.util';
import { ReportContext, ReportDefinition, ReportFilterOption } from '../report.types'; import { ReportContext, ReportDefinition, ReportFilterOption } from '../report.types';
import { import {
PAYER_EXPR, PAYER_EXPR,
@@ -10,47 +17,287 @@ import {
} from '../revenue-classification'; } from '../revenue-classification';
export const LEDGER_SIDES: ReportFilterOption[] = [ export const LEDGER_SIDES: ReportFilterOption[] = [
{ value: 'RECEIVABLE_CREDIT', label: 'Receivable — credit service (shipping line)' }, {
{ value: 'RECEIVABLE_OPEN', label: 'Receivable — open balance' }, value: 'RECEIVABLE_SL_UNBILLED',
{ value: 'PAYABLE_CANCELLATION', label: 'Payable — cancellation fee' }, label: 'Receivable — shipping-line service, not yet invoiced',
{ value: 'PAYABLE_UNDELIVERED', label: 'Payable — paid but not delivered' }, },
{ value: 'SETTLED', label: 'Settled' }, {
value: 'RECEIVABLE_SL_INVOICED',
label: 'Receivable — shipping-line invoice open',
},
{ value: 'RECEIVABLE_OPEN', label: 'Receivable — open invoice balance' },
{
value: 'PAYABLE_WAGON_CREDIT',
label: 'Payable — unapplied wagon-cancellation credit',
},
{ value: 'PAYABLE_PREPAID', label: 'Payable — paid but not delivered' },
]; ];
/** /**
* Which side of the ledger an invoice sits on. * Which side of the ledger a row sits on, and why the report is a union of
* three fact tables rather than a CASE over `invoices`.
* *
* Receivable = EDR delivered and is owed money — the shipping-line credit * RECEIVABLE — money EDR is owed. The shipping-line arrangement is service
* arrangement, plus any invoice still carrying a balance. * first, pay later, and it produces debt in two shapes: a `shipping_line_credits`
* Payable = the customer paid for something EDR did not deliver, so the money * row with NO invoice while it is UNBILLED (a shipping-line booking raises no
* is a refund liability rather than revenue: cancellation fees, and prepaid * invoice at all), and an open batch invoice once finance bills it. Counting
* invoices whose booking died. * only the second understates the debt by everything not yet batched. Ordinary
* open invoice balances are the third shape — including the wagon-cancellation
* FEE, which is money the customer owes EDR, never a liability.
*
* PAYABLE — the customer paid and did not get the service. Wagon cancellation
* never refunds cash: the cancelled freight becomes a rebooking credit that is
* redeemed by creating another booking (see BookingWagonCancellationService).
* So the liability is exactly the cancellations sitting in CREDIT_AVAILABLE —
* fee settled, wagons freed, credit not yet applied — valued at `credit_amount`,
* and it disappears the moment the row turns REBOOKED. The source invoice is
* useless for this: a whole-booking cut leaves it PAID at its full amount
* forever, which is neither the right number nor the right lifetime.
*
* Fully settled invoices are not rows here. A zero-exposure invoice is neither
* a receivable nor a payable; Invoicing Pipeline is the report that lists them.
*/ */
const SIDE_EXPR = `CASE
WHEN i.source = 'shipping_line_credit' OR i.type = 'SHIPPING_LINE_CREDIT'
THEN 'RECEIVABLE_CREDIT'
WHEN i.type = 'WAGON_CANCEL_FEE' THEN 'PAYABLE_CANCELLATION'
WHEN i.paid_amount > 0 AND b.status IN ('CANCELLED', 'REJECTED', 'EXPIRED')
THEN 'PAYABLE_UNDELIVERED'
WHEN i.balance_amount > 0 THEN 'RECEIVABLE_OPEN'
ELSE 'SETTLED'
END`;
const LABELS = new Map(LEDGER_SIDES.map((s) => [s.value, s.label])); const LABELS = new Map(LEDGER_SIDES.map((s) => [s.value, s.label]));
const SIDE_LABEL_EXPR = `CASE ${SIDE_EXPR}
${[...LABELS].map(([value, label]) => `WHEN '${value}' THEN '${label.replace(/'/g, "''")}'`).join('\n ')} /** Labels a side key that is already a column — the union is classified inside, labelled outside. */
const SIDE_LABEL_OF = (keyExpr: string): string =>
`CASE ${keyExpr}\n ${[...LABELS]
.map(([value, label]) => `WHEN '${value}' THEN '${label.replace(/'/g, "''")}'`)
.join('\n ')}\nEND`;
/**
* Statuses that cannot become cash. EXPIRED closed its own pay window and
* REFUNDED already gave the money back, so neither is owed in either
* direction. Filtered here rather than in the shared DEAD_INVOICE_STATUSES —
* that constant feeds every revenue report and those invoices did earn revenue.
*/
const UNCOLLECTABLE_INVOICE_STATUSES = "('EXPIRED', 'REFUNDED')";
/**
* A booking whose money is accounted for by the cancellation ledger instead.
* Without this, a whole-booking wagon cancellation would be counted twice: once
* as its own CREDIT_AVAILABLE credit, and again as the source booking's paid
* invoice sitting against a CANCELLED booking — and the second copy would never
* clear, because rebooking updates the ledger row, not the old invoice.
*/
const HAS_CANCELLATION_LEDGER = `EXISTS (
SELECT 1 FROM freight.booking_wagon_cancellations bwc0
WHERE bwc0.booking_id = b.id
AND bwc0.deleted_at IS NULL
AND bwc0.status <> 'WITHDRAWN'
)`;
/** Customer paid, booking died, and no cancellation credit represents it. */
const PREPAID_DEAD = `i.paid_amount > 0
AND b.status IN ('CANCELLED', 'REJECTED', 'EXPIRED')
AND NOT ${HAS_CANCELLATION_LEDGER}`;
export const INVOICE_SIDE_EXPR = `CASE
WHEN i.source = 'shipping_line_credit' OR i.type = 'SHIPPING_LINE_CREDIT'
THEN 'RECEIVABLE_SL_INVOICED'
WHEN ${PREPAID_DEAD} THEN 'PAYABLE_PREPAID'
ELSE 'RECEIVABLE_OPEN'
END`; END`;
/** Money at stake on this row: what is owed, or what may have to be given back. */ /**
const EXPOSURE = `CASE * The union's column contract, in positional order.
WHEN ${SIDE_EXPR} LIKE 'PAYABLE%' THEN i.paid_amount *
ELSE i.balance_amount * UNION matches by POSITION, and TypeORM does not preserve `addSelect` order —
END`; * it hoists a branch's repeated expressions to the front, which silently
* rearranged one branch into `gross, exposure, side_key, …` and failed with
* "UNION types text and numeric cannot be matched". Every branch is therefore
* re-projected through this list by name before it is unioned.
*/
const UNION_COLUMNS = [
'side_key',
'txn_date',
'doc_ref',
'booking_ref',
'booking_status',
'payer',
'gross',
'settled',
'exposure',
] as const;
/**
* The one wagon-cancellation status that is a live liability: the fee is
* settled and the booking cut, but the credit has not been turned into a
* booking yet. FEE_PENDING has cut nothing, REBOOKED has been redeemed, and
* WITHDRAWN/EXPIRED owe nothing.
*/
export const CREDIT_LIABILITY_STATUS = 'CREDIT_AVAILABLE';
/**
* Shipping-line credit status that is debt with no invoice behind it. BILLED
* credits are counted through their invoice on branch A, which is what keeps
* the two shipping-line sides disjoint.
*/
export const UNINVOICED_CREDIT_STATUS = 'UNBILLED';
/** Applies the filters branches B and C share with {@link invoiceLedgerQb}. */
function applySharedFilters(
qb: SelectQueryBuilder<ObjectLiteral>,
ctx: ReportContext,
dateExpr: string,
): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
if (params.dateFrom) qb.andWhere(`${dateExpr} >= :dateFrom`, { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere(`${dateExpr} < :dateTo`, { dateTo: params.dateTo });
if (params.origin) qb.andWhere('oy.code = :origin', { origin: params.origin });
if (params.destination) {
qb.andWhere('dy.code = :destination', { destination: params.destination });
}
if (params.customer) {
qb.andWhere(
'(co.name ILIKE :customer OR slc.name ILIKE :customer OR b.reference ILIKE :customer)',
{ customer: `%${params.customer as string}%` },
);
}
// An umbrella general contract is paid once and drawn down by many orders —
// same exclusion invoiceLedgerQb applies on branch A.
qb.andWhere("(b.id IS NULL OR b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')");
// Both branches reach their booking directly, so the direction scope is the
// plain column form, not the source_id-pointer form invoices need. A row
// whose booking is gone carries no direction to scope by and stays visible —
// the same rule applyBookingRefDirectionScope applies on branch A.
const scope = directionScopeSql('b.trade_direction', directions);
qb.andWhere(`(b.id IS NULL OR ${scope.sql})`, scope.params);
return qb;
}
/** Branch A — invoices carrying a balance, plus prepayments against dead bookings. */
function invoiceBranch(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
return invoiceLedgerQb(ctx)
.andWhere(`i.status NOT IN ${UNCOLLECTABLE_INVOICE_STATUSES}`)
.andWhere(`(i.balance_amount > 0 OR (${PREPAID_DEAD}))`)
.select(INVOICE_SIDE_EXPR, 'side_key')
.addSelect(REVENUE_DATE, 'txn_date')
.addSelect('i.invoice_number', 'doc_ref')
.addSelect("COALESCE(b.reference, '—')", 'booking_ref')
.addSelect("COALESCE(b.status, '—')", 'booking_status')
.addSelect(PAYER_EXPR, 'payer')
.addSelect('i.total_amount', 'gross')
.addSelect('i.paid_amount', 'settled')
.addSelect(
`CASE WHEN ${PREPAID_DEAD} THEN i.paid_amount ELSE i.balance_amount END`,
'exposure',
);
}
/**
* Branch B — shipping-line services used but never invoiced.
*
* The credit row IS the debt while it is UNBILLED; BILLED rows are the ones
* behind an invoice and are already counted by branch A, so taking only
* UNBILLED here is what keeps the two shipping-line sides disjoint.
*/
function unbilledCreditBranch(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const qb = ctx.ds
.createQueryBuilder()
.from(ShippingLineCredit, 'slc_c')
.leftJoin(Booking, 'b', 'b.id = slc_c.booking_id AND b.deleted_at IS NULL')
.leftJoin(Yard, 'oy', 'oy.id = b.origin_yard_id')
.leftJoin(Yard, 'dy', 'dy.id = b.destination_yard_id')
.leftJoin(Company, 'co', 'co.id = b.company_id')
.leftJoin(ShippingLineCompany, 'slc', 'slc.id = slc_c.shipping_line_company_id')
.where('slc_c.deleted_at IS NULL')
.andWhere('slc_c.status = :uninvoicedCreditStatus', {
uninvoicedCreditStatus: UNINVOICED_CREDIT_STATUS,
})
.andWhere('slc_c.currency = :currency', {
currency: currencyOf(ctx.params),
});
// Priced when the service was used; that is the date the debt was incurred.
applySharedFilters(qb, ctx, 'slc_c.created_at');
return qb
.select("'RECEIVABLE_SL_UNBILLED'", 'side_key')
.addSelect('slc_c.created_at', 'txn_date')
.addSelect("'—'", 'doc_ref')
.addSelect("COALESCE(b.reference, '—')", 'booking_ref')
.addSelect("COALESCE(b.status, '—')", 'booking_status')
.addSelect("COALESCE(slc.name, 'Unknown')", 'payer')
.addSelect('slc_c.amount', 'gross')
.addSelect('0::numeric', 'settled')
.addSelect('slc_c.amount', 'exposure');
}
/**
* Branch C — cancelled wagons whose credit has not been rebooked.
*
* `credit_amount` is priced in the BOOKING's payment currency, not
* `fee_currency` — that one prices the cancellation fee, which is a separate
* (and opposite-signed) piece of money.
*/
function wagonCreditBranch(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const qb = ctx.ds
.createQueryBuilder()
.from(BookingWagonCancellation, 'bwc')
.innerJoin(Booking, 'b', 'b.id = bwc.booking_id AND b.deleted_at IS NULL')
.leftJoin(Yard, 'oy', 'oy.id = b.origin_yard_id')
.leftJoin(Yard, 'dy', 'dy.id = b.destination_yard_id')
.leftJoin(Company, 'co', 'co.id = b.company_id')
.leftJoin(ShippingLineCompany, 'slc', 'slc.id = b.shipping_line_company_id')
.where('bwc.deleted_at IS NULL')
.andWhere('bwc.status = :creditLiabilityStatus', {
creditLiabilityStatus: CREDIT_LIABILITY_STATUS,
})
.andWhere("COALESCE(b.payment_currency, 'ETB') = :currency", {
currency: currencyOf(ctx.params),
});
// The credit exists from the moment the fee settled and the booking was cut.
applySharedFilters(qb, ctx, 'COALESCE(bwc.fee_paid_at, bwc.created_at)');
return (
qb
.select("'PAYABLE_WAGON_CREDIT'", 'side_key')
.addSelect('COALESCE(bwc.fee_paid_at, bwc.created_at)', 'txn_date')
// numeric(6,2) renders as "2.00"; a wagon count reads as "2" (and "2.5"
// survives, because a half wagon is a real bulk quantity here).
.addSelect(
`rtrim(rtrim(bwc.wagons_cancelled::text, '0'), '.') || ' wagon(s) cancelled'`,
'doc_ref',
)
.addSelect("COALESCE(b.reference, '—')", 'booking_ref')
.addSelect("COALESCE(b.status, '—')", 'booking_status')
.addSelect(PAYER_EXPR, 'payer')
// The freight was paid in full on the original booking, so the whole
// credit is money already in hand and owed back as bookable value.
.addSelect('bwc.credit_amount', 'gross')
.addSelect('bwc.credit_amount', 'settled')
.addSelect('bwc.credit_amount', 'exposure')
);
}
/**
* The three branches as one relation, wrapped so the runner can sort, page and
* COUNT(*) it like any other report query.
*
* Parameters are merged from every branch: `getQuery()` leaves `:name`
* placeholders in place, and only the outer builder's parameter bag is read
* when the SQL is finally bound.
*/
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> { function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const qb = invoiceLedgerQb(ctx); const branches = [invoiceBranch(ctx), unbilledCreditBranch(ctx), wagonCreditBranch(ctx)];
const combined = branches
.map((b, idx) => `SELECT ${UNION_COLUMNS.join(', ')} FROM (${b.getQuery()}) branch_${idx}`)
.join('\n UNION ALL\n ');
const qb = ctx.ds
.createQueryBuilder()
.from(`(${combined})`, 'r')
.setParameters(Object.assign({}, ...branches.map((b) => b.getParameters())));
const sides = ctx.params.sides as string[] | null; const sides = ctx.params.sides as string[] | null;
if (sides?.length) qb.andWhere(`${SIDE_EXPR} IN (:...sides)`, { sides }); if (sides?.length) qb.andWhere('r.side_key IN (:...sides)', { sides });
return qb; return qb;
} }
@@ -58,57 +305,113 @@ export const receivablesPayablesReport: ReportDefinition = {
key: 'receivables-payables', key: 'receivables-payables',
title: 'Receivables and Payables', title: 'Receivables and Payables',
description: description:
'Splits customer money two ways: receivable, where EDR delivered and is owed — ' + 'Splits open customer money two ways: receivable, where EDR delivered and is owed — ' +
'including shipping-line credit services — and payable, where the customer paid but ' + 'shipping-line credit services whether invoiced yet or not, plus any invoice still ' +
'the service was not delivered, such as cancellation fees and prepayments against ' + 'carrying a balance — and payable, where the customer paid and the service was not ' +
'dead bookings. Payable amounts are a refund liability, not revenue.', 'delivered. The payable is dominated by wagon cancellations whose credit has not been ' +
'rebooked; that credit is redeemed by creating another booking, never refunded in cash.',
group: 'Finance', group: 'Finance',
filters: [ filters: [
...REVENUE_FILTERS.filter((f) => f.key !== 'categories' && f.key !== 'methods'), ...REVENUE_FILTERS.filter((f) => f.key !== 'categories' && f.key !== 'methods'),
{ key: 'sides', label: 'Ledger side', type: 'multiselect', options: LEDGER_SIDES }, {
key: 'sides',
label: 'Ledger side',
type: 'multiselect',
options: LEDGER_SIDES,
},
], ],
columns: [ columns: [
{ key: 'side', label: 'Ledger side', type: 'string', sortable: true, sortExpr: SIDE_EXPR }, {
{ key: 'issuedAt', label: 'Issued', type: 'date', sortable: true, sortExpr: REVENUE_DATE }, key: 'side',
{ key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' }, label: 'Ledger side',
type: 'string',
sortable: true,
sortExpr: 'r.side_key',
},
{
key: 'issuedAt',
label: 'Date',
type: 'date',
sortable: true,
sortExpr: 'r.txn_date',
},
{
key: 'invoiceNumber',
label: 'Invoice / ref',
type: 'string',
sortable: true,
sortExpr: 'r.doc_ref',
},
{ key: 'bookingRef', label: 'Booking', type: 'string' }, { key: 'bookingRef', label: 'Booking', type: 'string' },
{ key: 'bookingStatus', label: 'Booking status', type: 'string' }, { key: 'bookingStatus', label: 'Booking status', type: 'string' },
{ key: 'customer', label: 'Payer', type: 'string', sortable: true, sortExpr: PAYER_EXPR }, {
{ key: 'invoiced', label: 'Invoiced', type: 'money', sortable: true, sortExpr: 'i.total_amount' }, key: 'customer',
{ key: 'paid', label: 'Paid', type: 'money', sortable: true, sortExpr: 'i.paid_amount' }, label: 'Payer',
{ key: 'exposure', label: 'Owed / refundable', type: 'money', sortable: true, sortExpr: EXPOSURE }, type: 'string',
sortable: true,
sortExpr: 'r.payer',
},
{
key: 'invoiced',
label: 'Amount',
type: 'money',
sortable: true,
sortExpr: 'r.gross',
},
{
key: 'paid',
label: 'Paid',
type: 'money',
sortable: true,
sortExpr: 'r.settled',
},
{
key: 'exposure',
label: 'Owed / refundable',
type: 'money',
sortable: true,
sortExpr: 'r.exposure',
},
], ],
defaultSort: { key: 'exposure', dir: 'DESC' }, defaultSort: { key: 'exposure', dir: 'DESC' },
chart: { type: 'bar', x: 'side', y: ['exposure'] }, chart: { type: 'bar', x: 'side', y: ['exposure'] },
query(ctx) { query(ctx) {
return baseQuery(ctx) return baseQuery(ctx)
.select(SIDE_LABEL_EXPR, 'side') .select(SIDE_LABEL_OF('r.side_key'), 'side')
.addSelect(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD')`, 'issuedAt') .addSelect("to_char(r.txn_date, 'YYYY-MM-DD')", 'issuedAt')
.addSelect('i.invoice_number', 'invoiceNumber') .addSelect('r.doc_ref', 'invoiceNumber')
.addSelect("COALESCE(b.reference, '—')", 'bookingRef') .addSelect('r.booking_ref', 'bookingRef')
.addSelect("COALESCE(b.status, '—')", 'bookingStatus') .addSelect('r.booking_status', 'bookingStatus')
.addSelect(PAYER_EXPR, 'customer') .addSelect('r.payer', 'customer')
.addSelect('ROUND(i.total_amount, 2)::float8', 'invoiced') .addSelect('ROUND(r.gross, 2)::float8', 'invoiced')
.addSelect('ROUND(i.paid_amount, 2)::float8', 'paid') .addSelect('ROUND(r.settled, 2)::float8', 'paid')
.addSelect(`ROUND(${EXPOSURE}, 2)::float8`, 'exposure'); .addSelect('ROUND(r.exposure, 2)::float8', 'exposure');
}, },
async summary(ctx) { async summary(ctx) {
const row = await baseQuery(ctx) const row = await baseQuery(ctx)
.select( .select(
`ROUND(COALESCE(SUM(${EXPOSURE}) FILTER (WHERE ${SIDE_EXPR} LIKE 'RECEIVABLE%'), 0))::float8`, "ROUND(COALESCE(SUM(r.exposure) FILTER (WHERE r.side_key LIKE 'RECEIVABLE%'), 0))::float8",
'receivable', 'receivable',
) )
.addSelect( .addSelect(
`ROUND(COALESCE(SUM(${EXPOSURE}) FILTER (WHERE ${SIDE_EXPR} LIKE 'PAYABLE%'), 0))::float8`, "ROUND(COALESCE(SUM(r.exposure) FILTER (WHERE r.side_key LIKE 'PAYABLE%'), 0))::float8",
'payable', 'payable',
) )
.addSelect('COUNT(*)::int', 'invoices') .addSelect('COUNT(*)::int', 'items')
.getRawOne<{ receivable: number; payable: number; invoices: number }>(); .getRawOne<{ receivable: number; payable: number; items: number }>();
const receivable = Number(row?.receivable ?? 0);
const payable = Number(row?.payable ?? 0);
const currency = currencyOf(ctx.params); const currency = currencyOf(ctx.params);
return [ return [
{ label: 'Receivable', value: Number(row?.receivable ?? 0), unit: currency }, { label: 'Receivable', value: receivable, unit: currency },
{ label: 'Payable', value: Number(row?.payable ?? 0), unit: currency }, { label: 'Payable', value: payable, unit: currency },
{ label: 'Invoices', value: Number(row?.invoices ?? 0) }, {
label: 'Net position',
value: Math.round(receivable - payable),
unit: currency,
},
{ label: 'Open items', value: Number(row?.items ?? 0) },
]; ];
}, },
}; };

View File

@@ -3,9 +3,10 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { ReportContext, ReportDefinition } from '../report.types'; import { ReportContext, ReportDefinition } from '../report.types';
import { import {
AVG_PER_UNIT_EXPR, AVG_PER_UNIT_EXPR,
CATEGORY_LABEL_EXPR, CATEGORY_LABEL_OF,
CONTAINERS_EXPR, CONTAINERS_EXPR,
PERIOD_FILTER, PERIOD_FILTER,
REVENUE_CATEGORIES,
REVENUE_CATEGORY_EXPR, REVENUE_CATEGORY_EXPR,
REVENUE_FILTERS, REVENUE_FILTERS,
REVENUE_SUM, REVENUE_SUM,
@@ -21,19 +22,35 @@ import {
const REVENUE = 'SUM(il.amount)'; const REVENUE = 'SUM(il.amount)';
/** /**
* Previous period's revenue for the same category. * Previous period's revenue for the same category, over the zero-filled grid.
* *
* Postgres evaluates window functions after GROUP BY, so `lag(SUM(...))` is * The window runs in the OUTER query, not alongside the aggregate. `lag()` only
* legal alongside the SUM — no self-join, no CTE. Both the PARTITION BY and the * ever sees the rows its own query level produces, so computing it inside the
* ORDER BY must repeat their grouping expressions verbatim: ordering by the * aggregate would skip straight over a category's silent periods — a category
* inner `date_trunc` when the group key is the `to_char` wrapper fails, and * billed in January and March would read March's prior as January and report
* ordinal shorthand (`ORDER BY 1`) is read as a constant inside a window * flat growth, hiding the month it earned nothing. Against the grid, February
* clause, silently producing an unordered partition. * exists at zero and both comparisons are real.
*/ */
const priorRevenue = (period: string): string => const PRIOR_REVENUE = 'lag(r.revenue) OVER (PARTITION BY r.category_key ORDER BY r.period)';
`lag(${REVENUE}) OVER (PARTITION BY ${REVENUE_CATEGORY_EXPR} ORDER BY ${period})`;
const growthPct = (period: string): string => growthPctExpr(REVENUE, priorRevenue(period)); /**
* Every category the grid must carry, narrowed to the caller's selection.
*
* This is where the `categories` filter is enforced for the table — the grid
* lists only what the caller asked for, and the join back to the aggregate
* drops the rest. See {@link revenueByCategoryReport.query} for why the filter
* cannot also be left on the aggregate.
*
* Intersected in JS against the constant list rather than interpolating the
* request's own values: the grid spells its categories into the SQL text, and a
* user-supplied string must never land there. An unrecognised value simply
* drops out — the ledger would match nothing on it anyway.
*/
const gridCategoryKeys = (params: Record<string, unknown>): string[] => {
const selected = params.categories as string[] | null;
const all = REVENUE_CATEGORIES.map((c) => c.value);
return selected?.length ? all.filter((key) => selected.includes(key)) : all;
};
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> { function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
return revenueLedgerQb(ctx); return revenueLedgerQb(ctx);
@@ -44,7 +61,9 @@ export const revenueByCategoryReport: ReportDefinition = {
title: 'Revenue by Category', title: 'Revenue by Category',
description: description:
'Billed revenue in the twelve rail revenue categories, per period, with volume and ' + 'Billed revenue in the twelve rail revenue categories, per period, with volume and ' +
'period-over-period growth. Growth compares against the previous period inside the ' + 'period-over-period growth. Every category is listed in every period that has revenue, ' +
'at zero when it was not billed, so a category going quiet reads as a drop rather than ' +
'a missing row. Growth compares against the previous period inside the ' +
'selected date range, so the earliest period always reads zero. ' + 'selected date range, so the earliest period always reads zero. ' +
'Multimodal means a named sea carrier is on the booking.', 'Multimodal means a named sea carrier is on the booking.',
group: 'Finance', group: 'Finance',
@@ -81,21 +100,89 @@ export const revenueByCategoryReport: ReportDefinition = {
}, },
query(ctx) { query(ctx) {
const period = periodExpr(ctx.params); const period = periodExpr(ctx.params);
return baseQuery(ctx)
/*
* One row per period/category that actually has lines. Revenue stays
* unrounded here so the growth window below divides the same numbers the
* old single-level query did; the display rounding happens in the wrapper.
*
* The category filter is deliberately dropped from this aggregate and
* applied by the grid instead. The period axis is built from whatever
* periods this aggregate produces, so filtering here would make the axis
* depend on the selection — pick a category that was never billed and
* there would be no periods left to hang its zero rows on, which is
* exactly the empty table the grid exists to prevent. Unselected
* categories still cost nothing: the grid never lists them, so the join
* drops them.
*/
const agg = revenueLedgerQb({ ...ctx, params: { ...ctx.params, categories: null } })
.select(period, 'period') .select(period, 'period')
.addSelect(CATEGORY_LABEL_EXPR, 'category') .addSelect(REVENUE_CATEGORY_EXPR, 'category_key')
.addSelect(REVENUE_CATEGORY_EXPR, 'categoryKey') .addSelect(REVENUE, 'revenue')
.addSelect(`ROUND(${REVENUE})::float8`, 'revenue')
.addSelect(`ROUND(COALESCE(${priorRevenue(period)}, 0))::float8`, 'priorRevenue')
.addSelect(`COALESCE(${growthPct(period)}, 0)`, 'growthPct')
.addSelect(`ROUND(COALESCE(${TONS_EXPR}, 0), 1)::float8`, 'tons') .addSelect(`ROUND(COALESCE(${TONS_EXPR}, 0), 1)::float8`, 'tons')
.addSelect(`ROUND(COALESCE(${TEU_EXPR}, 0))::int`, 'teu') .addSelect(`ROUND(COALESCE(${TEU_EXPR}, 0))::int`, 'teu')
.addSelect(`ROUND(COALESCE(${CONTAINERS_EXPR}, 0))::int`, 'containers') .addSelect(`ROUND(COALESCE(${CONTAINERS_EXPR}, 0))::int`, 'containers')
.addSelect(`COALESCE(${AVG_PER_UNIT_EXPR}, 0)`, 'avgPerUnit') .addSelect(`COALESCE(${AVG_PER_UNIT_EXPR}, 0)`, 'avg_per_unit')
.addSelect(UNIT_LABEL_EXPR, 'unit') .addSelect(UNIT_LABEL_EXPR, 'unit')
.addSelect('COUNT(*)::int', 'lines') .addSelect('COUNT(*)::int', 'lines')
.groupBy(period) .groupBy(period)
.addGroupBy(REVENUE_CATEGORY_EXPR); .addGroupBy(REVENUE_CATEGORY_EXPR);
const categoryKeys = gridCategoryKeys(ctx.params)
.map((key) => `'${key}'`)
.join(', ');
/*
* The grid: every period that has revenue at all, crossed with every
* category the filter allows, then LEFT JOINed back to the aggregate so an
* unbilled category lands at zero instead of vanishing.
*
* Periods come from the data, NOT from generate_series over the date
* filter. A default twelve-month range over a database with one billed
* month would otherwise publish eleven months of pure zeros, and a daily
* granularity would multiply that by thirty. A period that saw no revenue
* in ANY category is still absent; a category that saw none in a live
* period is not — and because the aggregate above ignores the category
* filter, "live" means live for the business, not live for the selection.
*
* `unnest(ARRAY[...])` rather than `VALUES` because an empty array is legal
* and yields no rows — `VALUES` with nothing in it is a syntax error, and a
* filter naming only unrecognised categories produces exactly that list.
*/
const grid = `
WITH agg AS (${agg.getQuery()})
SELECT g.period,
g.category_key,
COALESCE(a.revenue, 0) AS revenue,
COALESCE(a.tons, 0) AS tons,
COALESCE(a.teu, 0) AS teu,
COALESCE(a.containers, 0) AS containers,
COALESCE(a.avg_per_unit, 0) AS avg_per_unit,
COALESCE(a.unit, '') AS unit,
COALESCE(a.lines, 0) AS lines
FROM (
SELECT p.period, c.category_key
FROM (SELECT DISTINCT period FROM agg) p
CROSS JOIN unnest(ARRAY[${categoryKeys}]::text[]) AS c(category_key)
) g
LEFT JOIN agg a ON a.period = g.period AND a.category_key = g.category_key`;
return ctx.ds
.createQueryBuilder()
.from(`(${grid})`, 'r')
.setParameters(agg.getParameters())
.select('r.period', 'period')
.addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category')
.addSelect('r.category_key', 'categoryKey')
.addSelect('ROUND(r.revenue)::float8', 'revenue')
.addSelect(`ROUND(COALESCE(${PRIOR_REVENUE}, 0))::float8`, 'priorRevenue')
.addSelect(`COALESCE(${growthPctExpr('r.revenue', PRIOR_REVENUE)}, 0)`, 'growthPct')
.addSelect('r.tons::float8', 'tons')
.addSelect('r.teu::int', 'teu')
.addSelect('r.containers::int', 'containers')
.addSelect('r.avg_per_unit::float8', 'avgPerUnit')
.addSelect('r.unit', 'unit')
.addSelect('r.lines::int', 'lines');
}, },
async summary(ctx) { async summary(ctx) {
const row = await baseQuery(ctx) const row = await baseQuery(ctx)
@@ -113,7 +200,10 @@ export const revenueByCategoryReport: ReportDefinition = {
const currency = currencyOf(ctx.params); const currency = currencyOf(ctx.params);
return [ return [
{ label: 'Total revenue', value: Number(row?.revenue ?? 0), unit: currency }, { label: 'Total revenue', value: Number(row?.revenue ?? 0), unit: currency },
{ label: 'Categories', value: Number(row?.categories ?? 0) }, // "with revenue" is not decoration: the table now lists every category in
// every live period, so a bare "Categories: 6" next to fourteen rows
// would read as a contradiction rather than as the count of live ones.
{ label: 'Categories with revenue', value: Number(row?.categories ?? 0) },
// Always shown, even at zero: an audit report must never quietly drop money. // Always shown, even at zero: an audit report must never quietly drop money.
{ label: 'Unclassified', value: Number(row?.unclassified ?? 0), unit: currency }, { label: 'Unclassified', value: Number(row?.unclassified ?? 0), unit: currency },
]; ];

View File

@@ -1,91 +1,101 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity'; import { ReportContext, ReportColumn, ReportDefinition } from '../report.types';
import { Company } from '../../companies/entities/company.entity'; import {
import { ReportContext, ReportDefinition } from '../report.types'; PAID_SHARE,
PAYER_EXPR,
PAYMENT_CLASSES,
PAYMENT_CLASS_EXPR,
REVENUE_FILTERS,
REVENUE_SUM,
currencyOf,
revenueLedgerQb,
} from '../revenue-classification';
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; /**
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)'; * One column per payment class, pivoted with FILTER. The class values are the
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')"; * compile-time constants in PAYMENT_CLASSES, never user input, so they are
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED']; * safe to interpolate.
*/
const CLASS_COLUMNS = PAYMENT_CLASSES.map((c) => ({
value: c.value,
key: c.value.toLowerCase().replace(/_(.)/g, (_, ch: string) => ch.toUpperCase()),
label: c.label,
}));
const classMoneyColumns: ReportColumn[] = CLASS_COLUMNS.map((c) => ({
key: c.key,
label: c.label,
type: 'money',
sortable: true,
}));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> { function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx; return revenueLedgerQb(ctx);
const qb = ctx.ds
.createQueryBuilder()
.from(Booking, 'b')
.innerJoin(Company, 'c', 'c.id = b.company_id')
.where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`);
if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo });
if (params.direction) qb.andWhere('b.trade_direction = :direction', { direction: params.direction });
if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType });
const statuses = params.statuses as string[] | null;
if (statuses) {
qb.andWhere('b.status IN (:...statuses)', { statuses });
} else {
qb.andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES });
}
if (directions !== null) {
qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', {
directions,
});
}
return qb;
} }
export const revenueByCustomerReport: ReportDefinition = { export const revenueByCustomerReport: ReportDefinition = {
key: 'revenue-by-customer', key: 'revenue-by-customer',
title: 'Revenue by Customer', title: 'Revenue by Customer',
description: 'Ranked customers by booking revenue', description:
group: 'Commercial', 'Every paying customer on one row: total billed revenue, what they have settled, ' +
filters: [ 'what is still open, and a column per charge type — rail transport, customs ' +
{ key: 'date', label: 'Created', type: 'daterange' }, 'clearance, first/last mile, overweight, cancellation, demurrage, storage, loading ' +
{ 'and unloading, and additional charges. Built on invoice lines, so the charge-type ' +
key: 'direction', 'split is the billed one; a booking total is a lump sum and cannot be split. The ' +
label: 'Direction', 'payer is the company or, for shipping-line credit invoices, the shipping line. ' +
type: 'select', 'There is no dedicated loading/unloading charge type in the system — handling, ' +
options: [ 'double-handling and lashing stand in for it.',
{ value: 'IMPORT', label: 'Import' }, group: 'Finance',
{ value: 'EXPORT', label: 'Export' }, filters: REVENUE_FILTERS,
{ value: 'DOMESTIC', label: 'Domestic' },
],
},
{
key: 'freightType',
label: 'Freight type',
type: 'select',
options: [
{ value: 'CONTAINER', label: 'Container' },
{ value: 'BULK', label: 'Bulk' },
],
},
{ key: 'statuses', label: 'Status', type: 'multiselect' },
],
columns: [ columns: [
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' }, {
{ key: 'bookings', label: 'Bookings', type: 'number', sortable: true }, key: 'customer',
{ key: 'tons', label: 'Tonnage', type: 'tons', sortable: true }, label: 'Customer',
{ key: 'revenue', label: 'Revenue', type: 'money', sortable: true }, type: 'string',
sortable: true,
sortExpr: PAYER_EXPR,
},
{ key: 'revenue', label: 'Total revenue', type: 'money', sortable: true },
{ key: 'paid', label: 'Paid', type: 'money', sortable: true },
{ key: 'outstanding', label: 'Outstanding', type: 'money', sortable: true },
...classMoneyColumns,
{ key: 'invoices', label: 'Invoices', type: 'number', sortable: true },
], ],
defaultSort: { key: 'revenue', dir: 'DESC' }, defaultSort: { key: 'revenue', dir: 'DESC' },
chart: { type: 'bar', x: 'customer', y: ['revenue'] },
drill: { to: 'revenue-transactions', carry: { customer: 'customer' } },
query(ctx) { query(ctx) {
return baseQuery(ctx) const qb = baseQuery(ctx)
.select('c.name', 'customer') .select(PAYER_EXPR, 'customer')
.addSelect('COUNT(*)::int', 'bookings') .addSelect(REVENUE_SUM, 'revenue')
.addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons') .addSelect(`ROUND(COALESCE(SUM(${PAID_SHARE}), 0))::float8`, 'paid')
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue') .addSelect(`ROUND(COALESCE(SUM(il.amount - (${PAID_SHARE})), 0))::float8`, 'outstanding')
.groupBy('c.name'); .addSelect('COUNT(DISTINCT i.id)::int', 'invoices')
.groupBy(PAYER_EXPR);
for (const c of CLASS_COLUMNS) {
qb.addSelect(
`ROUND(COALESCE(SUM(il.amount) FILTER (WHERE ${PAYMENT_CLASS_EXPR} = '${c.value}'), 0))::float8`,
c.key,
);
}
return qb;
}, },
async summary(ctx) { async summary(ctx) {
const row = await baseQuery(ctx) const row = await baseQuery(ctx)
.select('COUNT(DISTINCT c.name)::int', 'customers') .select(`COUNT(DISTINCT ${PAYER_EXPR})::int`, 'customers')
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue') .addSelect(REVENUE_SUM, 'revenue')
.getRawOne(); .addSelect(`ROUND(COALESCE(SUM(${PAID_SHARE}), 0))::float8`, 'paid')
.getRawOne<{ customers: number; revenue: number; paid: number }>();
const revenue = Number(row?.revenue ?? 0);
const paid = Number(row?.paid ?? 0);
const unit = currencyOf(ctx.params);
return [ return [
{ label: 'Customers', value: Number(row?.customers ?? 0) }, { label: 'Customers', value: Number(row?.customers ?? 0) },
{ label: 'Revenue', value: Number(row?.revenue ?? 0), unit: 'ETB' }, { label: 'Total revenue', value: revenue, unit },
{ label: 'Paid', value: paid, unit },
{ label: 'Outstanding', value: Math.round(revenue - paid), unit },
]; ];
}, },
}; };

View File

@@ -1,62 +0,0 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { ReportContext, ReportDefinition } from '../report.types';
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Booking, 'b')
.where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`)
.andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES });
if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo });
if (directions !== null) {
qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', { directions });
}
return qb;
}
export const revenueSummaryReport: ReportDefinition = {
key: 'revenue-summary',
title: 'Revenue Summary',
description: 'Booking revenue by direction, cargo type and currency',
group: 'Finance',
filters: [{ key: 'date', label: 'Created', type: 'daterange' }],
columns: [
{ key: 'direction', label: 'Direction', type: 'string', sortable: true },
{ key: 'freightType', label: 'Cargo type', type: 'string', sortable: true },
{ key: 'currency', label: 'Currency', type: 'string' },
{ key: 'bookings', label: 'Bookings', type: 'number', sortable: true },
{ key: 'revenue', label: 'Revenue', type: 'money', sortable: true },
],
defaultSort: { key: 'revenue', dir: 'DESC' },
chart: { type: 'bar', x: 'direction', y: ['revenue'] },
query(ctx) {
return baseQuery(ctx)
.select('b.trade_direction', 'direction')
.addSelect('b.freight_type', 'freightType')
.addSelect('b.payment_currency', 'currency')
.addSelect('COUNT(*)::int', 'bookings')
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue')
.groupBy('b.trade_direction')
.addGroupBy('b.freight_type')
.addGroupBy('b.payment_currency');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue')
.addSelect('COUNT(*)::int', 'bookings')
.getRawOne();
return [
{ label: 'Bookings', value: Number(row?.bookings ?? 0) },
{ label: 'Total revenue', value: Number(row?.revenue ?? 0), unit: 'ETB' },
];
},
};

View File

@@ -1,6 +1,6 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; import { ObjectLiteral, SelectQueryBuilder } from "typeorm";
import { ReportContext, ReportDefinition } from '../report.types'; import { ReportContext, ReportDefinition } from "../report.types";
import { import {
CONTAINER_CLASSES, CONTAINER_CLASSES,
CONTAINER_CLASS_EXPR, CONTAINER_CLASS_EXPR,
@@ -10,12 +10,13 @@ import {
OPERATIONS_FILTERS, OPERATIONS_FILTERS,
TEU_EXPR, TEU_EXPR,
allocationLedgerQb, allocationLedgerQb,
attainmentCtx,
PLAN_GRANULARITY_NOTE, PLAN_GRANULARITY_NOTE,
implementRateExpr, implementRateExpr,
plannedRowsParams, plannedRowsParams,
plannedRowsSql, plannedRowsSql,
} from '../operations-classification'; } from "../operations-classification";
import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-classification'; import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from "../revenue-classification";
const CONTAINERS_20 = `COALESCE(SUM(( const CONTAINERS_20 = `COALESCE(SUM((
SELECT COUNT(*) FROM freight.wagon_allocation_container_items ci SELECT COUNT(*) FROM freight.wagon_allocation_container_items ci
@@ -39,44 +40,53 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
} }
export const teuPerformanceReport: ReportDefinition = { export const teuPerformanceReport: ReportDefinition = {
key: 'teu-performance', key: "teu-performance",
title: 'TEU Performance', title: "TEU Performance",
description: description:
'Twenty-foot equivalent units moved per container class against plan. Every 40ft box ' + "Twenty-foot equivalent units moved per container class against plan. Every 40ft box " +
'counts as two TEU, so ten 40ft and thirty 20ft is 50 TEU. Counted from the ' + "counts as two TEU, so ten 40ft and thirty 20ft is 50 TEU. Counted from the " +
'marshalling record — the containers actually allocated to wagons — not from the ' + "marshalling record — the containers actually allocated to wagons — not from the " +
'billing lines. Plan comes from Operational targets.' + "billing lines. Plan comes from Operational targets." +
PLAN_GRANULARITY_NOTE, PLAN_GRANULARITY_NOTE,
group: 'Operations', group: "Operations",
filters: [ filters: [
PERIOD_FILTER, PERIOD_FILTER,
...OPERATIONS_FILTERS, ...OPERATIONS_FILTERS,
{ key: 'classes', label: 'Container class', type: 'multiselect', options: CONTAINER_CLASSES }, { key: "classes", label: "Container class", type: "multiselect", options: CONTAINER_CLASSES },
], ],
columns: [ columns: [
{ key: 'period', label: 'Period', type: 'string', sortable: true }, { key: "period", label: "Period", type: "string", sortable: true },
{ key: 'containerClass', label: 'Container type', type: 'string', sortable: true }, { key: "containerClass", label: "Container type", type: "string", sortable: true },
{ key: 'containers20', label: '20ft', type: 'number', sortable: true }, { key: "containers20", label: "20ft", type: "number", sortable: true },
{ key: 'containers40', label: '40ft', type: 'number', sortable: true }, { key: "containers40", label: "40ft", type: "number", sortable: true },
{ key: 'containers', label: 'Containers', type: 'number', sortable: true }, { key: "operated", label: "Operated (TEU)", type: "number", sortable: true },
{ key: 'operated', label: 'Operated (TEU)', type: 'number', sortable: true }, { key: "plan", label: "Plan", type: "number" },
{ key: 'plan', label: 'Plan', type: 'number' }, { key: "planRequired", label: "Required", type: "number" },
{ key: 'implementRate', label: 'Implement rate', type: 'percent' }, { key: "implementRate", label: "Implement rate", type: "percent" },
], ],
defaultSort: { key: 'operated', dir: 'DESC' }, defaultSort: { key: "operated", dir: "DESC" },
chart: { type: 'bar', x: 'containerClass', y: ['operated'] }, chart: { type: "bar", x: "containerClass", y: ["operated"] },
query(ctx) { query(ctx) {
const bucket = periodTruncExprOn(OPS_DATE, ctx.params); const bucket = periodTruncExprOn(OPS_DATE, ctx.params);
const operated = baseQuery(ctx) const operated = baseQuery(ctx)
.select(periodExprOn(OPS_DATE, ctx.params), 'period') .select(periodExprOn(OPS_DATE, ctx.params), "period")
.addSelect(CONTAINER_CLASS_EXPR, 'class_key') .addSelect(CONTAINER_CLASS_EXPR, "class_key")
.addSelect(CONTAINERS_20, 'containers20') .addSelect(CONTAINERS_20, "containers20")
.addSelect(CONTAINERS_40, 'containers40') .addSelect(CONTAINERS_40, "containers40")
.addSelect(CONTAINERS_EXPR, 'containers') .addSelect(TEU_EXPR, "operated")
.addSelect(TEU_EXPR, 'operated')
.groupBy(bucket) .groupBy(bucket)
.addGroupBy(CONTAINER_CLASS_EXPR); .addGroupBy(CONTAINER_CLASS_EXPR);
// Attainment for the cascade: TEU across the target's whole period, so a
// mid-year view does not read as "nothing shipped yet".
const attained = baseQuery(attainmentCtx(ctx))
.select(periodTruncExprOn(OPS_DATE, ctx.params), "bucket")
.addSelect(CONTAINER_CLASS_EXPR, "act_key")
.addSelect("NULL::varchar", "act_category")
.addSelect(TEU_EXPR, "actual")
.groupBy(periodTruncExprOn(OPS_DATE, ctx.params))
.addGroupBy(CONTAINER_CLASS_EXPR);
// Full outer join so a planned container class that never moved still // Full outer join so a planned container class that never moved still
// reports, at zero rather than vanishing. // reports, at zero rather than vanishing.
const combined = ` const combined = `
@@ -84,38 +94,47 @@ export const teuPerformanceReport: ReportDefinition = {
COALESCE(o.class_key, p.plan_key) AS class_key, COALESCE(o.class_key, p.plan_key) AS class_key,
COALESCE(o.containers20, 0) AS containers20, COALESCE(o.containers20, 0) AS containers20,
COALESCE(o.containers40, 0) AS containers40, COALESCE(o.containers40, 0) AS containers40,
COALESCE(o.containers, 0) AS containers,
COALESCE(o.operated, 0) AS operated, COALESCE(o.operated, 0) AS operated,
p.plan_value AS plan p.plan_value AS plan,
p.plan_required AS plan_required
FROM (${operated.getQuery()}) o FROM (${operated.getQuery()}) o
FULL OUTER JOIN (${plannedRowsSql('TEU', 'container_class', ctx.params)}) p FULL OUTER JOIN (${plannedRowsSql(
"TEU",
"container_class",
ctx.params,
attained.getQuery(),
)}) p
ON p.period = o.period AND p.plan_key = o.class_key`; ON p.period = o.period AND p.plan_key = o.class_key`;
return ctx.ds return ctx.ds
.createQueryBuilder() .createQueryBuilder()
.from(`(${combined})`, 'r') .from(`(${combined})`, "r")
.setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) }) .setParameters({
.select('r.period', 'period') ...operated.getParameters(),
.addSelect(CONTAINER_CLASS_LABEL_OF('r.class_key'), 'containerClass') ...attained.getParameters(),
.addSelect('r.class_key', 'containerClassKey') ...plannedRowsParams(ctx.params),
.addSelect('r.containers20::int', 'containers20') })
.addSelect('r.containers40::int', 'containers40') .select("r.period", "period")
.addSelect('r.containers::int', 'containers') .addSelect(CONTAINER_CLASS_LABEL_OF("r.class_key"), "containerClass")
.addSelect('r.operated::int', 'operated') .addSelect("r.class_key", "containerClassKey")
.addSelect('r.plan::float8', 'plan') .addSelect("r.containers20::int", "containers20")
.addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate'); .addSelect("r.containers40::int", "containers40")
.addSelect("r.operated::int", "operated")
.addSelect("r.plan::float8", "plan")
.addSelect("r.plan_required::float8", "planRequired")
.addSelect(implementRateExpr("r.operated", "r.plan"), "implementRate");
}, },
async summary(ctx) { async summary(ctx) {
const row = await baseQuery(ctx) const row = await baseQuery(ctx)
.select(TEU_EXPR, 'teu') .select(TEU_EXPR, "teu")
.addSelect(CONTAINERS_EXPR, 'containers') .addSelect(CONTAINERS_EXPR, "containers")
.addSelect('COUNT(DISTINCT ts.id)::int', 'trains') .addSelect("COUNT(DISTINCT ts.id)::int", "trains")
.getRawOne<{ teu: number; containers: number; trains: number }>(); .getRawOne<{ teu: number; containers: number; trains: number }>();
return [ return [
{ label: 'TEU', value: Number(row?.teu ?? 0) }, { label: "TEU", value: Number(row?.teu ?? 0) },
{ label: 'Containers', value: Number(row?.containers ?? 0) }, { label: "Containers", value: Number(row?.containers ?? 0) },
{ label: 'Trains', value: Number(row?.trains ?? 0) }, { label: "Trains", value: Number(row?.trains ?? 0) },
]; ];
}, },
}; };

View File

@@ -13,6 +13,7 @@ import {
TRAINSETS_EXPR, TRAINSETS_EXPR,
allocationLedgerQb, allocationLedgerQb,
applyCategoryFilter, applyCategoryFilter,
attainmentCtx,
PLAN_GRANULARITY_NOTE, PLAN_GRANULARITY_NOTE,
implementRateExpr, implementRateExpr,
plannedRowsParams, plannedRowsParams,
@@ -45,6 +46,7 @@ export const trainsetPerformanceReport: ReportDefinition = {
{ key: 'wagons', label: 'Wagons', type: 'number', sortable: true }, { key: 'wagons', label: 'Wagons', type: 'number', sortable: true },
{ key: 'operated', label: 'Operated (trainsets)', type: 'number', sortable: true }, { key: 'operated', label: 'Operated (trainsets)', type: 'number', sortable: true },
{ key: 'plan', label: 'Plan', type: 'number' }, { key: 'plan', label: 'Plan', type: 'number' },
{ key: 'planRequired', label: 'Required', type: 'number' },
{ key: 'implementRate', label: 'Implement rate', type: 'percent' }, { key: 'implementRate', label: 'Implement rate', type: 'percent' },
], ],
defaultSort: { key: 'operated', dir: 'DESC' }, defaultSort: { key: 'operated', dir: 'DESC' },
@@ -60,6 +62,16 @@ export const trainsetPerformanceReport: ReportDefinition = {
.groupBy(bucket) .groupBy(bucket)
.addGroupBy(CARGO_CATEGORY_EXPR); .addGroupBy(CARGO_CATEGORY_EXPR);
// Attainment for the cascade: the same trainset measure across the target's
// whole period, not just the window the viewer is looking at.
const attained = baseQuery(attainmentCtx(ctx))
.select(periodTruncExprOn(OPS_DATE, ctx.params), 'bucket')
.addSelect(CARGO_CATEGORY_EXPR, 'act_key')
.addSelect('NULL::varchar', 'act_category')
.addSelect(TRAINSETS_EXPR, 'actual')
.groupBy(periodTruncExprOn(OPS_DATE, ctx.params))
.addGroupBy(CARGO_CATEGORY_EXPR);
// FULL OUTER JOIN so a category that was planned but never ran still shows, // FULL OUTER JOIN so a category that was planned but never ran still shows,
// at zero — TypeORM's builder has no full-outer join, hence the raw text. // at zero — TypeORM's builder has no full-outer join, hence the raw text.
const combined = ` const combined = `
@@ -68,15 +80,25 @@ export const trainsetPerformanceReport: ReportDefinition = {
COALESCE(o.trains, 0) AS trains, COALESCE(o.trains, 0) AS trains,
COALESCE(o.wagons, 0) AS wagons, COALESCE(o.wagons, 0) AS wagons,
COALESCE(o.operated, 0) AS operated, COALESCE(o.operated, 0) AS operated,
p.plan_value AS plan p.plan_value AS plan,
p.plan_required AS plan_required
FROM (${operated.getQuery()}) o FROM (${operated.getQuery()}) o
FULL OUTER JOIN (${plannedRowsSql('TRAINSET', 'cargo_category', ctx.params)}) p FULL OUTER JOIN (${plannedRowsSql(
'TRAINSET',
'cargo_category',
ctx.params,
attained.getQuery(),
)}) p
ON p.period = o.period AND p.plan_key = o.category_key`; ON p.period = o.period AND p.plan_key = o.category_key`;
return ctx.ds return ctx.ds
.createQueryBuilder() .createQueryBuilder()
.from(`(${combined})`, 'r') .from(`(${combined})`, 'r')
.setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) }) .setParameters({
...operated.getParameters(),
...attained.getParameters(),
...plannedRowsParams(ctx.params),
})
.select('r.period', 'period') .select('r.period', 'period')
.addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category') .addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category')
.addSelect('r.category_key', 'categoryKey') .addSelect('r.category_key', 'categoryKey')
@@ -84,6 +106,7 @@ export const trainsetPerformanceReport: ReportDefinition = {
.addSelect('r.wagons::int', 'wagons') .addSelect('r.wagons::int', 'wagons')
.addSelect('r.operated::float8', 'operated') .addSelect('r.operated::float8', 'operated')
.addSelect('r.plan::float8', 'plan') .addSelect('r.plan::float8', 'plan')
.addSelect('r.plan_required::float8', 'planRequired')
.addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate'); .addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate');
}, },
async summary(ctx) { async summary(ctx) {

View File

@@ -503,21 +503,68 @@ export function applyCategoryFilter(
} }
/** /**
* The planned rows for a metric, as a derived table. * Appended to every plan-versus-actual report's description, because neither
* the re-bucketing nor the catch-up rule is guessable from the table.
*/
export const PLAN_GRANULARITY_NOTE =
' A plan is spread evenly across its own period and re-gathered into whichever bucket ' +
'the report shows, so a monthly target fills a quarter or a year exactly, and a daily ' +
'or weekly view gets its share of it. A week that straddles two months draws on both. ' +
'Plan is the committed figure and never moves. Required is the same target treated as a ' +
'quota: whatever is still outstanding, spread across the time still left, so a period ' +
'that fell behind raises what the periods after it must carry. A target already met in ' +
'full requires nothing further.';
/**
* The user's date filter as open-ended bounds, so the clipping arithmetic below
* never has to branch on null.
*/
const PLAN_FROM = "COALESCE(CAST(:planFrom AS timestamptz), '-infinity'::timestamptz)";
const PLAN_TO = "COALESCE(CAST(:planTo AS timestamptz), 'infinity'::timestamptz)";
/**
* How long one target's period runs. A target's span is exact — 90 days is 90
* days — and need not line up with the ragged year-end display blocks the
* `nine_month` and `ninety_day` granularities produce. The spread below is
* proportional, so partial overlap resolves correctly either way.
*/
const TARGET_SPAN = `CASE ot.period_type
WHEN 'day' THEN INTERVAL '1 day'
WHEN 'week' THEN INTERVAL '7 days'
WHEN 'month' THEN INTERVAL '1 month'
WHEN 'quarter' THEN INTERVAL '3 months'
WHEN 'half_year' THEN INTERVAL '6 months'
WHEN 'nine_month' THEN INTERVAL '9 months'
WHEN 'ninety_day' THEN INTERVAL '90 days'
WHEN 'year' THEN INTERVAL '1 year'
ELSE INTERVAL '1 day'
END`;
/**
* The planned rows for a metric, as a derived table: one row per bucket per
* planned key, carrying both a committed and a required figure.
* *
* A target is a rate over its own period, not a lump at its start: the plan is * **Plan** — a target is a rate over its own period, not a lump at its start.
* spread evenly across the days it covers, then re-gathered into the report's * The committed value is spread evenly across the days it covers and
* buckets. One rule covers every direction — three monthly targets add up to a * re-gathered into the report's buckets, so three monthly targets add up to a
* quarter exactly, a daily view gets a thirty-first of the month, and a week * quarter exactly, a daily view gets a thirty-first of the month, and a week
* straddling a month boundary draws proportionally on both months. * straddling a month boundary draws proportionally on both. The even spread is
* an assumption, and the only one available: a monthly figure carries no
* information about which days inside it were busier. This number never moves —
* Implement Rate is measured against it, so a month that missed keeps reading
* as a month that missed.
* *
* The even spread is an assumption, and the only one available: a monthly * **Required** — the same target read as a quota. At each bucket, whatever is
* figure carries no information about which days inside it were busier. * still outstanding (committed minus everything delivered in earlier buckets)
* is spread across the time still left in the period. A year 20% met at the
* halfway mark asks the remaining months for the other 80%. Over-delivery
* clamps to zero rather than going negative: a met quota requires nothing more.
* *
* The share is clipped to the user's date filter as well as to the bucket, so * `actualsSql` must produce `(bucket, act_key, act_category, actual)` and must
* the plan always covers exactly the span the operated figure beside it covers. * be built **without the user's date bounds** — see {@link attainmentCtx}.
* Without that, filtering to July and viewing by year would put a whole year's * Attainment is a fact about the target's whole period; measuring it through
* plan next to one month's work. * the report's date filter would read a mid-year view as "nothing delivered
* yet" and demand the entire year's work from one month.
* *
* The reports FULL OUTER JOIN this to their operated aggregate so a category * The reports FULL OUTER JOIN this to their operated aggregate so a category
* that was planned but never ran still appears, at zero. The OCC monthly report * that was planned but never ran still appears, at zero. The OCC monthly report
@@ -528,62 +575,96 @@ export function applyCategoryFilter(
* Period bounds ride on `:planFrom` / `:planTo`, which the caller must bind * Period bounds ride on `:planFrom` / `:planTo`, which the caller must bind
* with {@link plannedRowsParams} — they come from the user's date filter. * with {@link plannedRowsParams} — they come from the user's date filter.
*/ */
/**
* Appended to every plan-versus-actual report's description, because the
* re-bucketing rule is not guessable from the table.
*/
export const PLAN_GRANULARITY_NOTE =
' A plan is spread evenly across its own period and re-gathered into whichever bucket ' +
'the report shows, so a monthly target fills a quarter or a year exactly, and a daily ' +
'or weekly view gets its share of it. A week that straddles two months draws on both.';
/**
* The user's date filter as open-ended bounds, so the clipping arithmetic below
* never has to branch on null.
*/
const PLAN_FROM = "COALESCE(CAST(:planFrom AS timestamptz), '-infinity'::timestamptz)";
const PLAN_TO = "COALESCE(CAST(:planTo AS timestamptz), 'infinity'::timestamptz)";
export const plannedRowsSql = ( export const plannedRowsSql = (
metric: string, metric: string,
dimension: string, dimension: string,
params: Record<string, unknown>, params: Record<string, unknown>,
actualsSql: string,
): string => { ): string => {
const unit = resolvePeriod(params); const unit = resolvePeriod(params);
// Reused verbatim in the GROUP BY, per the trap documented on `periodExpr`.
const bucketOf = unit.truncOn('d.day');
return ` return `
SELECT to_char(g.bucket, '${unit.fmt}') AS period, WITH tgt AS (
ot.dimension_key AS plan_key, SELECT ot.id,
ot.cargo_category AS plan_category, ot.dimension_key,
SUM(ot.planned_value * ( ot.cargo_category,
GREATEST(0, EXTRACT(EPOCH FROM ( ot.planned_value,
LEAST(g.bucket + INTERVAL '${unit.step}', t.ends, ${PLAN_TO}) ot.period_start::timestamptz AS starts,
- GREATEST(g.bucket, ot.period_start::timestamptz, ${PLAN_FROM})))) ot.period_start::timestamptz + ${TARGET_SPAN} AS ends
/ NULLIF(EXTRACT(EPOCH FROM (t.ends - ot.period_start)), 0) FROM freight.operations_targets ot
)) AS plan_value WHERE ot.deleted_at IS NULL
FROM freight.operations_targets ot AND ot.metric = '${metric}'
CROSS JOIN LATERAL ( AND ot.dimension = '${dimension}'
SELECT ot.period_start + CASE ot.period_type AND ot.planned_value > 0
WHEN 'week' THEN INTERVAL '7 days' ),
WHEN 'month' THEN INTERVAL '1 month' -- One row per target per bucket. Generated a day at a time rather than a
WHEN 'quarter' THEN INTERVAL '3 months' -- bucket at a time: the ragged units restart their blocks each January, so
WHEN 'year' THEN INTERVAL '1 year' -- stepping by the unit's own width walks off the anchor in the second year.
ELSE INTERVAL '1 day' -- Day grain also makes a bucket that only partly overlaps the target fall out
END AS ends -- for free, at the same sub-day precision the clipping used before.
) t spread AS (
CROSS JOIN LATERAL generate_series( SELECT t.id,
date_trunc('${unit.trunc}', ot.period_start::timestamptz), t.dimension_key,
date_trunc('${unit.trunc}', t.ends - INTERVAL '1 microsecond'), t.cargo_category,
INTERVAL '${unit.step}' t.planned_value,
) AS g(bucket) EXTRACT(EPOCH FROM (t.ends - t.starts)) AS secs_total,
WHERE ot.deleted_at IS NULL ${bucketOf} AS bucket,
AND ot.metric = '${metric}' SUM(GREATEST(0, EXTRACT(EPOCH FROM (
AND ot.dimension = '${dimension}' LEAST(d.day + INTERVAL '1 day', t.ends)
AND g.bucket + INTERVAL '${unit.step}' > ${PLAN_FROM} - GREATEST(d.day, t.starts))))) AS secs_full,
AND g.bucket < ${PLAN_TO} SUM(GREATEST(0, EXTRACT(EPOCH FROM (
GROUP BY 1, 2, 3 LEAST(d.day + INTERVAL '1 day', t.ends, ${PLAN_TO})
HAVING SUM(ot.planned_value) > 0`; - GREATEST(d.day, t.starts, ${PLAN_FROM}))))) AS secs_in
FROM tgt t
CROSS JOIN LATERAL generate_series(
date_trunc('day', t.starts),
t.ends - INTERVAL '1 microsecond',
INTERVAL '1 day'
) AS d(day)
GROUP BY t.id, t.dimension_key, t.cargo_category, t.planned_value,
t.starts, t.ends, ${bucketOf}
),
-- secs_before and actual_before are strictly-preceding running sums, so a
-- bucket's requirement is decided by what happened before it, never by its
-- own result. The frame is spelled out rather than defaulted: the default
-- RANGE frame would fold peer rows into the current one.
cascaded AS (
SELECT s.*,
COALESCE(SUM(s.secs_full) OVER prior, 0) AS secs_before,
COALESCE(SUM(a.actual) OVER prior, 0) AS actual_before
FROM spread s
LEFT JOIN (${actualsSql}) a
ON a.bucket = s.bucket
AND a.act_key = s.dimension_key
AND a.act_category IS NOT DISTINCT FROM s.cargo_category
WINDOW prior AS (
PARTITION BY s.id ORDER BY s.bucket
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
)
)
SELECT ${unit.labelOn('c.bucket')} AS period,
c.dimension_key AS plan_key,
c.cargo_category AS plan_category,
SUM(c.planned_value * c.secs_in / NULLIF(c.secs_total, 0)) AS plan_value,
SUM(GREATEST(0, c.planned_value - c.actual_before)
* c.secs_in / NULLIF(c.secs_total - c.secs_before, 0)) AS plan_required
FROM cascaded c
WHERE c.secs_in > 0
GROUP BY 1, 2, 3`;
}; };
/**
* The report's own ledger with the user's date bounds removed, for the
* attainment series {@link plannedRowsSql} cascades from. Every other filter
* stays applied, so the catch-up figure is measured on the same population as
* the `operated` column it sits beside.
*/
export const attainmentCtx = (ctx: ReportContext): ReportContext => ({
...ctx,
params: { ...ctx.params, dateFrom: null, dateTo: null },
});
/** The bindings {@link plannedRowsSql} expects. */ /** The bindings {@link plannedRowsSql} expects. */
export const plannedRowsParams = ( export const plannedRowsParams = (
params: Record<string, unknown>, params: Record<string, unknown>,

View File

@@ -1,45 +1,39 @@
import { ReportKey } from '../../seed/freight-permissions.registry'; import { ReportKey } from "../../seed/freight-permissions.registry";
import { bookingsListReport } from './definitions/bookings-list.report'; import { revenueByCustomerReport } from "./definitions/revenue-by-customer.report";
import { revenueByCustomerReport } from './definitions/revenue-by-customer.report'; import { agingReceivablesReport } from "./definitions/aging-receivables.report";
import { agingReceivablesReport } from './definitions/aging-receivables.report'; import { contractUtilizationReport } from "./definitions/contract-utilization.report";
import { contractUtilizationReport } from './definitions/contract-utilization.report'; import { wagonFleetStatusReport } from "./definitions/wagon-fleet-status.report";
import { wagonFleetStatusReport } from './definitions/wagon-fleet-status.report'; import { wagonStatusDurationReport } from "./definitions/wagon-status-duration.report";
import { wagonStatusDurationReport } from './definitions/wagon-status-duration.report'; import { wagonRequestsReport } from "./definitions/wagon-requests.report";
import { wagonRequestsReport } from './definitions/wagon-requests.report'; import { locomotiveFleetStatusReport } from "./definitions/locomotive-fleet-status.report";
import { locomotiveFleetStatusReport } from './definitions/locomotive-fleet-status.report'; import { bookingStatusBreakdownReport } from "./definitions/booking-status-breakdown.report";
import { bookingStatusBreakdownReport } from './definitions/booking-status-breakdown.report'; import { trainScheduleStatusReport } from "./definitions/train-schedule-status.report";
import { trainScheduleStatusReport } from './definitions/train-schedule-status.report'; import { trainTurnaroundReport } from "./definitions/train-turnaround.report";
import { trainTurnaroundReport } from './definitions/train-turnaround.report'; import { wagonTeuUtilizationReport } from "./definitions/wagon-teu-utilization.report";
import { wagonTeuUtilizationReport } from './definitions/wagon-teu-utilization.report'; import { loadedCapacityReport } from "./definitions/loaded-capacity.report";
import { loadedCapacityReport } from './definitions/loaded-capacity.report'; import { globalLogisticsWagonsReport } from "./definitions/global-logistics-wagons.report";
import { globalLogisticsWagonsReport } from './definitions/global-logistics-wagons.report'; import { customsDocumentsReport } from "./definitions/customs-documents.report";
import { customerStatusReport } from './definitions/customer-status.report'; import { invoicingPipelineReport } from "./definitions/invoicing-pipeline.report";
import { contractLifecycleReport } from './definitions/contract-lifecycle.report'; import { firstLastMileBookingsReport } from "./definitions/first-last-mile-bookings.report";
import { customsDocumentsReport } from './definitions/customs-documents.report'; import { cargoSummaryReport } from "./definitions/cargo-summary.report";
import { invoicingPipelineReport } from './definitions/invoicing-pipeline.report'; import { revenueByCategoryReport } from "./definitions/revenue-by-category.report";
import { firstLastMileBookingsReport } from './definitions/first-last-mile-bookings.report'; import { revenueTransactionsReport } from "./definitions/revenue-transactions.report";
import { invoicesByStatusReport } from './definitions/invoices-by-status.report'; import { revenueByPeriodReport } from "./definitions/revenue-by-period.report";
import { paymentsByStatusReport } from './definitions/payments-by-status.report'; import { revenueByRouteReport } from "./definitions/revenue-by-route.report";
import { revenueSummaryReport } from './definitions/revenue-summary.report'; import { revenueTopCustomersReport } from "./definitions/revenue-top-customers.report";
import { cargoSummaryReport } from './definitions/cargo-summary.report'; import { paymentClassificationReport } from "./definitions/payment-classification.report";
import { revenueByCategoryReport } from './definitions/revenue-by-category.report'; import { revenueReconciliationReport } from "./definitions/revenue-reconciliation.report";
import { revenueTransactionsReport } from './definitions/revenue-transactions.report'; import { receivablesPayablesReport } from "./definitions/receivables-payables.report";
import { revenueByPeriodReport } from './definitions/revenue-by-period.report'; import { revenueAnomaliesReport } from "./definitions/revenue-anomalies.report";
import { revenueByRouteReport } from './definitions/revenue-by-route.report'; import { stationStayingTimeReport } from "./definitions/station-staying-time.report";
import { revenueTopCustomersReport } from './definitions/revenue-top-customers.report'; import { turnaroundCycleReport } from "./definitions/turnaround-cycle.report";
import { paymentClassificationReport } from './definitions/payment-classification.report'; import { trainDelaysReport } from "./definitions/train-delays.report";
import { revenueReconciliationReport } from './definitions/revenue-reconciliation.report'; import { trainsetPerformanceReport } from "./definitions/trainset-performance.report";
import { receivablesPayablesReport } from './definitions/receivables-payables.report'; import { teuPerformanceReport } from "./definitions/teu-performance.report";
import { revenueAnomaliesReport } from './definitions/revenue-anomalies.report'; import { cargoVolumePerformanceReport } from "./definitions/cargo-volume-performance.report";
import { stationStayingTimeReport } from './definitions/station-staying-time.report'; import { chargedVsActualVolumeReport } from "./definitions/charged-vs-actual-volume.report";
import { turnaroundCycleReport } from './definitions/turnaround-cycle.report'; import { cargoVolumeByStationReport } from "./definitions/cargo-volume-by-station.report";
import { trainDelaysReport } from './definitions/train-delays.report'; import { ReportDefinition } from "./report.types";
import { trainsetPerformanceReport } from './definitions/trainset-performance.report';
import { teuPerformanceReport } from './definitions/teu-performance.report';
import { cargoVolumePerformanceReport } from './definitions/cargo-volume-performance.report';
import { chargedVsActualVolumeReport } from './definitions/charged-vs-actual-volume.report';
import { cargoVolumeByStationReport } from './definitions/cargo-volume-by-station.report';
import { ReportDefinition } from './report.types';
/** /**
* Every report the platform knows about. Adding one = a new file under * Every report the platform knows about. Adding one = a new file under
@@ -47,7 +41,6 @@ import { ReportDefinition } from './report.types';
* an entry here. Nothing else — no frontend edit, no route, no sidebar edit. * an entry here. Nothing else — no frontend edit, no route, no sidebar edit.
*/ */
export const REPORTS: ReportDefinition[] = [ export const REPORTS: ReportDefinition[] = [
bookingsListReport,
revenueByCustomerReport, revenueByCustomerReport,
agingReceivablesReport, agingReceivablesReport,
contractUtilizationReport, contractUtilizationReport,
@@ -61,14 +54,9 @@ export const REPORTS: ReportDefinition[] = [
wagonTeuUtilizationReport, wagonTeuUtilizationReport,
loadedCapacityReport, loadedCapacityReport,
globalLogisticsWagonsReport, globalLogisticsWagonsReport,
customerStatusReport,
contractLifecycleReport,
customsDocumentsReport, customsDocumentsReport,
invoicingPipelineReport, invoicingPipelineReport,
firstLastMileBookingsReport, firstLastMileBookingsReport,
invoicesByStatusReport,
paymentsByStatusReport,
revenueSummaryReport,
cargoSummaryReport, cargoSummaryReport,
revenueByCategoryReport, revenueByCategoryReport,
revenueTransactionsReport, revenueTransactionsReport,
@@ -89,7 +77,9 @@ export const REPORTS: ReportDefinition[] = [
cargoVolumeByStationReport, cargoVolumeByStationReport,
]; ];
const BY_KEY = new Map<ReportKey, ReportDefinition>(REPORTS.map((r) => [r.key, r])); const BY_KEY = new Map<ReportKey, ReportDefinition>(
REPORTS.map((r) => [r.key, r]),
);
export function getReport(key: string): ReportDefinition | undefined { export function getReport(key: string): ReportDefinition | undefined {
return BY_KEY.get(key as ReportKey); return BY_KEY.get(key as ReportKey);

View File

@@ -86,17 +86,54 @@ describe('revenue classification', () => {
expect(periodExpr({ period: 'quarter' })).toContain("date_trunc('quarter'"); expect(periodExpr({ period: 'quarter' })).toContain("date_trunc('quarter'");
expect(periodExpr({ period: 'year' })).toContain("date_trunc('year'"); expect(periodExpr({ period: 'year' })).toContain("date_trunc('year'");
// Anything unrecognised — including an injection attempt — becomes 'month'. // Anything unrecognised — including an injection attempt — becomes 'month'.
expect(periodExpr({ period: "day'); DROP TABLE freight.invoices; --" })).toContain( const injection = "day'); DROP TABLE freight.invoices; --";
"date_trunc('month'", expect(periodExpr({ period: injection })).toContain("date_trunc('month'");
); expect(periodExpr({ period: injection })).not.toContain('DROP TABLE');
expect(periodExpr({})).toContain("date_trunc('month'"); expect(periodExpr({})).toContain("date_trunc('month'");
}); });
it('offers exactly the period units the expression understands', () => { it('offers exactly the period units the expression understands', () => {
const offered = (PERIOD_FILTER.options ?? []).map((o) => o.value); const offered = (PERIOD_FILTER.options ?? []).map((o) => o.value);
expect(offered.length).toBe(5); expect(offered).toEqual([
for (const unit of offered) { 'day',
expect(periodExpr({ period: unit })).toContain(`date_trunc('${unit}'`); 'week',
'month',
'quarter',
'half_year',
'nine_month',
'ninety_day',
'year',
]);
// Every offered unit resolves to its own expression rather than silently
// falling through to the month default — which is what a missing entry or a
// typo'd key would look like.
const expressions = offered.map((unit) => periodExpr({ period: unit }));
expect(new Set(expressions).size).toBe(offered.length);
});
/**
* Half-year, nine-month and ninety-day have no `date_trunc` unit, so they are
* offset arithmetic anchored to January 1st. These pin the anchor: they are
* the SQL half of a pair whose other half is `normalisePeriodStart` in
* `operations-targets.service.ts`, and a target that snaps to a boundary the
* report does not bucket on plans against a period that does not exist.
*/
it('anchors the irregular units to the start of the calendar year', () => {
for (const unit of ['half_year', 'nine_month', 'ninety_day']) {
const expr = periodExpr({ period: unit });
expect(expr).toContain("date_trunc('year'");
expect(expr).not.toContain(`date_trunc('${unit}'`);
} }
// Six- and nine-month blocks count whole months from January.
expect(periodExpr({ period: 'half_year' })).toContain("INTERVAL '6 months'");
expect(periodExpr({ period: 'nine_month' })).toContain("INTERVAL '9 months'");
// 90-day blocks count days, and cap at the fourth so the last days of
// December widen block four instead of forming a 5-day stub of their own.
const ninety = periodExpr({ period: 'ninety_day' });
expect(ninety).toContain("INTERVAL '90 days'");
expect(ninety).toContain('LEAST(');
expect(ninety).toContain('/ 90, 3)');
}); });
}); });

View File

@@ -139,8 +139,15 @@ const labelCase = (expr: string, options: ReportFilterOption[]): string =>
.map((o) => `WHEN '${o.value}' THEN '${o.label.replace(/'/g, "''")}'`) .map((o) => `WHEN '${o.value}' THEN '${o.label.replace(/'/g, "''")}'`)
.join('\n ')}\nEND`; .join('\n ')}\nEND`;
/**
* The same labelling applied to a key that is already a column — for reports
* that classify in a subquery and label in the wrapper.
*/
export const CATEGORY_LABEL_OF = (keyExpr: string): string =>
labelCase(keyExpr, REVENUE_CATEGORIES);
/** The category as a business label rather than its key, for display columns. */ /** The category as a business label rather than its key, for display columns. */
export const CATEGORY_LABEL_EXPR = labelCase(REVENUE_CATEGORY_EXPR, REVENUE_CATEGORIES); export const CATEGORY_LABEL_EXPR = CATEGORY_LABEL_OF(REVENUE_CATEGORY_EXPR);
/** /**
* Period-over-period change, as a percentage. * Period-over-period change, as a percentage.
@@ -223,22 +230,103 @@ END`;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /**
* Frozen whitelist. The runner coerces a `select` filter to a trimmed string * A granularity, as SQL builders rather than fragments to interpolate.
* or null; that string is used only as an object key here, so the user's value
* never reaches SQL — one of five compile-time constants does.
* *
* Every format is zero-padded, so lexicographic order equals chronological * Five of the eight are plain `date_trunc` units. The other three — half-year,
* order. The growth window depends on that. * nine-month, ninety-day — have no `date_trunc` equivalent in Postgres, so they
* are offset arithmetic from the start of the calendar year. Builders let both
* kinds live behind one interface.
*/ */
const PERIOD_UNITS = { interface PeriodUnit {
day: { trunc: 'day', fmt: 'YYYY-MM-DD', label: 'Daily', step: '1 day' }, label: string;
week: { trunc: 'week', fmt: 'IYYY-"W"IW', label: 'Weekly', step: '1 week' }, /** Interval one whole block wide. Only exact for the six regular units. */
month: { trunc: 'month', fmt: 'YYYY-MM', label: 'Monthly', step: '1 month' }, step: string;
/** Timestamp expression → the start of the block that timestamp falls in. */
truncOn: (dateExpr: string) => string;
/** Block-start expression → its display label. */
labelOn: (truncExpr: string) => string;
/**
* Block-start expression → the start of the NEXT block. Not always
* `+ step`: a ragged unit's final block of the year is shorter than its own
* step, so stepping past it overshoots into the wrong block.
*/
nextStartOn: (truncExpr: string) => string;
}
const regular = (trunc: string, fmt: string, label: string, step: string): PeriodUnit => ({
label,
step,
truncOn: (dateExpr) => `date_trunc('${trunc}', ${dateExpr})`,
labelOn: (truncExpr) => `to_char(${truncExpr}, '${fmt}')`,
nextStartOn: (truncExpr) => `(${truncExpr} + INTERVAL '${step}')`,
});
/**
* Blocks of `months` months counted from January, so they reset every calendar
* year. Six divides twelve and nine does not: a nine-month year is JanSep plus
* a short OctDec. That ragged tail is inherent to the unit — the alternative
* is blocks that drift out of the calendar, which is not what "calendar
* anchored" means.
*/
const monthBlocks = (months: number, marker: string, label: string): PeriodUnit => ({
label,
step: `${months} months`,
truncOn: (dateExpr) =>
`(date_trunc('year', ${dateExpr})` +
` + (((EXTRACT(MONTH FROM ${dateExpr})::int - 1) / ${months}) * INTERVAL '${months} months'))`,
labelOn: (truncExpr) =>
`(to_char(${truncExpr}, 'YYYY') || '-${marker}' ||` +
` ((EXTRACT(MONTH FROM ${truncExpr})::int - 1) / ${months} + 1)::text)`,
nextStartOn: (truncExpr) =>
`LEAST(${truncExpr} + INTERVAL '${months} months',` +
` date_trunc('year', ${truncExpr}) + INTERVAL '1 year')`,
});
/**
* Frozen whitelist. The runner coerces a `select` filter to a trimmed string or
* null; that string is used only as an object key here, so the user's value
* never reaches SQL — one of eight compile-time constants does.
*
* Every label is zero-padded or single-digit-bounded, so lexicographic order
* equals chronological order. The growth windows depend on that.
*/
const PERIOD_UNITS: Record<string, PeriodUnit> = {
day: regular('day', 'YYYY-MM-DD', 'Daily', '1 day'),
week: regular('week', 'IYYY-"W"IW', 'Weekly', '1 week'),
month: regular('month', 'YYYY-MM', 'Monthly', '1 month'),
// `quarter` is a valid date_trunc unit but NOT a valid interval unit — // `quarter` is a valid date_trunc unit but NOT a valid interval unit —
// INTERVAL '1 quarter' is a syntax error, so the step is spelled in months. // INTERVAL '1 quarter' is a syntax error, so the step is spelled in months.
quarter: { trunc: 'quarter', fmt: 'YYYY-"Q"Q', label: 'Quarterly', step: '3 months' }, quarter: regular('quarter', 'YYYY-"Q"Q', 'Quarterly', '3 months'),
year: { trunc: 'year', fmt: 'YYYY', label: 'Yearly', step: '1 year' }, half_year: monthBlocks(6, 'H', 'Half-yearly'),
} as const; nine_month: monthBlocks(9, 'N', 'Nine-monthly'),
/**
* Four 90-day blocks from January 1st: days 1, 91, 181, 271.
*
* The block index is capped at 3 on purpose. Uncapped, `(doy - 1) / 90` puts
* December 27th onwards in a fifth block — a 5-day stub bucket at the end of
* every year, which is noise rather than a period. Capping instead lets the
* fourth block absorb the remainder and run 95 or 96 days.
*
* The label carries the zero-padded start day-of-year, which keeps it sorting
* chronologically and — unlike an ordinal — says out loud that the blocks are
* day-counted rather than month-aligned.
*/
ninety_day: {
label: '90-day',
step: '90 days',
truncOn: (dateExpr) =>
`(date_trunc('year', ${dateExpr})` +
` + (LEAST((EXTRACT(DOY FROM ${dateExpr})::int - 1) / 90, 3) * INTERVAL '90 days'))`,
labelOn: (truncExpr) =>
`(to_char(${truncExpr}, 'YYYY') || '-D' || lpad(EXTRACT(DOY FROM ${truncExpr})::int::text, 3, '0'))`,
// The fourth block ends with the year, not 90 days after it started.
nextStartOn: (truncExpr) =>
`(CASE WHEN EXTRACT(DOY FROM ${truncExpr})::int >= 271` +
` THEN date_trunc('year', ${truncExpr}) + INTERVAL '1 year'` +
` ELSE ${truncExpr} + INTERVAL '90 days' END)`,
},
year: regular('year', 'YYYY', 'Yearly', '1 year'),
};
export const PERIOD_FILTER: ReportFilterDef = { export const PERIOD_FILTER: ReportFilterDef = {
key: 'period', key: 'period',
@@ -267,10 +355,8 @@ export function periodExpr(params: Record<string, unknown>): string {
return periodExprOn(REVENUE_DATE, params); return periodExprOn(REVENUE_DATE, params);
} }
export function resolvePeriod( export function resolvePeriod(params: Record<string, unknown>): PeriodUnit {
params: Record<string, unknown>, const key = String(params.period ?? '');
): (typeof PERIOD_UNITS)[keyof typeof PERIOD_UNITS] {
const key = String(params.period ?? '') as keyof typeof PERIOD_UNITS;
return PERIOD_UNITS[key] ?? PERIOD_UNITS.month; return PERIOD_UNITS[key] ?? PERIOD_UNITS.month;
} }
@@ -280,10 +366,10 @@ export function resolvePeriod(
* these units so a month means the same thing on both sides of the product. * these units so a month means the same thing on both sides of the product.
*/ */
export const periodExprOn = (dateExpr: string, params: Record<string, unknown>): string => export const periodExprOn = (dateExpr: string, params: Record<string, unknown>): string =>
`to_char(${periodTruncExprOn(dateExpr, params)}, '${resolvePeriod(params).fmt}')`; resolvePeriod(params).labelOn(periodTruncExprOn(dateExpr, params));
export const periodTruncExprOn = (dateExpr: string, params: Record<string, unknown>): string => export const periodTruncExprOn = (dateExpr: string, params: Record<string, unknown>): string =>
`date_trunc('${resolvePeriod(params).trunc}', ${dateExpr})`; resolvePeriod(params).truncOn(dateExpr);
/** The period's start timestamp — what to GROUP BY when a report needs it numerically. */ /** The period's start timestamp — what to GROUP BY when a report needs it numerically. */
export const periodTruncExpr = (params: Record<string, unknown>): string => export const periodTruncExpr = (params: Record<string, unknown>): string =>
@@ -298,9 +384,16 @@ export const periodTruncExpr = (params: Record<string, unknown>): string =>
export const periodOrdinalExpr = (params: Record<string, unknown>): string => export const periodOrdinalExpr = (params: Record<string, unknown>): string =>
`EXTRACT(EPOCH FROM ${periodTruncExpr(params)})`; `EXTRACT(EPOCH FROM ${periodTruncExpr(params)})`;
/** Same scale, one period later — where a one-step-ahead projection lands. */ /**
* Same scale, one period later — where a one-step-ahead projection lands.
*
* Asks the unit rather than adding its step, because the two differ for the
* ragged units: a nine-month year's second block is three months long, and a
* 90-day year's fourth is 95, so `+ step` would land past the next block start
* and evaluate the regression at the wrong x.
*/
export const nextPeriodOrdinalExpr = (params: Record<string, unknown>): string => export const nextPeriodOrdinalExpr = (params: Record<string, unknown>): string =>
`EXTRACT(EPOCH FROM ${periodTruncExpr(params)} + INTERVAL '${resolvePeriod(params).step}')`; `EXTRACT(EPOCH FROM ${resolvePeriod(params).nextStartOn(periodTruncExpr(params))})`;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Volume — measured at line grain, never joined from the booking // Volume — measured at line grain, never joined from the booking

View File

@@ -17,8 +17,9 @@ export type WagonAdjustmentAction = (typeof WAGON_ADJUSTMENT_ACTIONS)[number];
@Index(['trainScheduleId']) @Index(['trainScheduleId'])
@Index(['trainId']) @Index(['trainId'])
export class ScheduleWagonAdjustmentLog extends BaseEntity { export class ScheduleWagonAdjustmentLog extends BaseEntity {
@Column({ name: 'train_schedule_id', type: 'uuid' }) /** Null when the change was made from the train builder with no live schedule. */
trainScheduleId!: string; @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
trainScheduleId!: string | null;
@Column({ name: 'train_id', type: 'uuid' }) @Column({ name: 'train_id', type: 'uuid' })
trainId!: string; trainId!: string;

View File

@@ -130,6 +130,33 @@ export class TrainSchedule extends BaseEntity {
@Column({ name: 'planned_wagon_yards', type: 'jsonb', nullable: true }) @Column({ name: 'planned_wagon_yards', type: 'jsonb', nullable: true })
plannedWagonYards?: Record<string, string> | null; plannedWagonYards?: Record<string, string> | null;
/**
* Where THIS departure plans to CUT (detach and leave) each consist wagon:
* `{ wagonId: yardId }`. Sparse — a wagon absent from the map rides to the
* schedule destination. A cap, not a promise: cargo may alight earlier, but
* validation forbids cargo allocated past the cut.
*/
@Column({ name: 'planned_wagon_cut_yards', type: 'jsonb', nullable: true })
plannedWagonCutYards?: Record<string, string> | null;
/**
* LOOSE wagons this departure plans to COUPLE onto the train at a route
* stop: `{ wagonId: pickupYardId }`. They join the built train permanently
* when the trip reaches that stop (dispatch for the origin, checkpoint log
* for mid-route stops).
*/
@Column({ name: 'planned_wagon_couples', type: 'jsonb', nullable: true })
plannedWagonCouples?: Record<string, string> | null;
/**
* Cut wagons (see plannedWagonCutYards) flagged as REAL cuts: the built
* train permanently loses the wagon at its cut yard. Absent from this list,
* a cut is soft — the wagon sits out the rest of this trip but stays in
* the build.
*/
@Column({ name: 'planned_wagon_real_cuts', type: 'jsonb', nullable: true })
plannedWagonRealCuts?: string[] | null;
/** OPEN = accepting/holding bookings; FULL = train filled; CLOSED = manually closed. Orthogonal to `status`. */ /** OPEN = accepting/holding bookings; FULL = train filled; CLOSED = manually closed. Orthogonal to `status`. */
@Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' }) @Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' })
bookingWindowStatus!: string; bookingWindowStatus!: string;

View File

@@ -18,6 +18,30 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
return manager ? manager.getRepository(TrainSchedule) : this.repository; return manager ? manager.getRepository(TrainSchedule) : this.repository;
} }
/**
* Slim consist view for read paths that only need the route stops, the
* built train, and slot→allocation existence (e.g. the schedule-yards tab):
* skips the booking/company/container branches of the full graph, which
* dominate its cost and go unused there.
*/
findByIdWithConsistLite(id: string): Promise<TrainSchedule | null> {
return this.repository.findOne({
where: { id },
relationLoadStrategy: 'query',
relations: {
route: { milestones: { yard: true } },
trainSet: {
train: true,
locomotive: true,
locomotives: { locomotive: true },
wagons: { allocations: true },
},
originStation: true,
destinationStation: true,
},
});
}
findByIdWithFullGraph(id: string, manager?: EntityManager): Promise<TrainSchedule | null> { findByIdWithFullGraph(id: string, manager?: EntityManager): Promise<TrainSchedule | null> {
return this.repo(manager).findOne({ return this.repo(manager).findOne({
where: { id }, where: { id },

View File

@@ -1536,4 +1536,72 @@ describe('BookingBatchService — physical wagon-type gate', () => {
// Those 16 are now held, so the next booking in the pass cannot re-take them. // Those 16 are now held, so the next booking in the pass cannot re-take them.
expect(stock.availableFor([NW5], WHOLE_LEG)).toBe(0); expect(stock.availableFor([NW5], WHOLE_LEG)).toBe(0);
}); });
it('sizes a capped-bulk partial on ONE type at the cargo cap, not the 70T rating', async () => {
const svc = service();
const inner = internals(svc);
(inner as { isSplitEligible: unknown }).isSplitEligible = () => true;
const dims = { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 };
(inner as unknown as { loadWagonDims: unknown }).loadWagonDims = async () => ({
container: dims,
bulk: dims,
byWagonTypeId: new Map([
[NW5, dims],
[PW2, dims],
]),
});
const tryPartial = jest
.fn()
.mockResolvedValue({ wagons: 16, weightTons: 864, lengthMeters: 224 });
(inner as { tryPartialOffer: unknown }).tryPartialOffer = tryPartial;
const stock = mixedStock();
const candidate = {
id: 'schedule-1',
budget: {
legOf: () => WHOLE_LEG,
remainingFor: () => ({ wagons: 20, weightTons: 99_999, lengthMeters: 99_999 }),
subtract: jest.fn(),
},
armed: false,
stock,
};
const booking = {
id: 'b2',
reference: 'BK-2',
originYardId: 'a',
destinationYardId: 'b',
freightType: 'BULK',
cargoTotalWeightVgm: 695,
cargoType: {
id: 'cargo-perishable',
wagonTypes: [
{ id: NW5, capacityTons: 70 },
{ id: PW2, capacityTons: 70 },
],
tonsPerWagonMap: { [NW5]: 30, [PW2]: 20 },
},
bookingContainers: [],
} as unknown as Booking;
const offered = await inner.maybeOfferPartial(
booking,
false,
[candidate],
{ wagons: 24, weightTons: 1400, lengthMeters: 336 },
[NW5, PW2],
);
expect(offered).toBe(true);
// Room capped to the 16 NW5 that exist (biggest capped take), and the seat
// carries the 30T cargo cap — never the wagon's raw 70T rating.
expect(tryPartial.mock.calls[0][2]).toMatchObject({ wagons: 16 });
expect(tryPartial.mock.calls[0][4]).toMatchObject({
wagonTypeId: NW5,
perWagon: { capacityTons: 30 },
});
// Only the seated type is held; the PW2s stay free for bulk-only cargo.
expect(stock.availableFor([NW5], WHOLE_LEG)).toBe(0);
expect(stock.availableFor([PW2], WHOLE_LEG)).toBe(4);
});
}); });

View File

@@ -10,6 +10,7 @@ import {
Optional, Optional,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm'; import { InjectDataSource } from '@nestjs/typeorm';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { SchedulerRegistry } from '@nestjs/schedule'; import { SchedulerRegistry } from '@nestjs/schedule';
import { import {
Between, Between,
@@ -92,13 +93,16 @@ import { BookingWindowGateway } from './booking-window.gateway';
import { import {
MAX_TEU_SLOTS_PER_WAGON, MAX_TEU_SLOTS_PER_WAGON,
containerWagonsForLines, containerWagonsForLines,
roundTons,
} from './utils/wagon-plan.util'; } from './utils/wagon-plan.util';
import { import {
Capacity, Capacity,
CorridorBudget, CorridorBudget,
CorridorLeg, CorridorLeg,
OverageTolerance, OverageTolerance,
addCoupledWagons,
stopYardsFor, stopYardsFor,
subtractCutWagons,
} from './corridor-capacity.util'; } from './corridor-capacity.util';
import { WagonStockLedger } from './wagon-stock-ledger.util'; import { WagonStockLedger } from './wagon-stock-ledger.util';
@@ -398,6 +402,8 @@ export class BookingBatchService implements OnModuleInit {
@Optional() private readonly milestoneService?: ClearanceMilestoneService, @Optional() private readonly milestoneService?: ClearanceMilestoneService,
@Optional() private readonly splitService?: BookingSplitService, @Optional() private readonly splitService?: BookingSplitService,
// Optional so hand-constructed spec instances keep compiling.
@Optional() private readonly eventEmitter?: EventEmitter2,
@Optional() @Optional()
@Inject(forwardRef(() => RemainderPlacementService)) @Inject(forwardRef(() => RemainderPlacementService))
private readonly remainderPlacement?: RemainderPlacementService, private readonly remainderPlacement?: RemainderPlacementService,
@@ -1484,6 +1490,44 @@ export class BookingBatchService implements OnModuleInit {
"Train is full — no export capacity left for this day", "Train is full — no export capacity left for this day",
); );
} }
// Physical wagon gate — a pay window must never open for wagons that do
// not exist in a type this cargo can ride. PER_TON bulk is seated
// type-by-type at its per-wagon caps (the count allocation will really
// need); everything else checks the summed free stock of its types.
const stock = await this.stockLedgerFor(
schedule,
budget,
bookings.map((b) => b.id),
);
const allowedWagonTypes = await this.loadAllowedWagonTypeIds();
const primary = bookings[0];
const wagonTypeIds = this.allowedWagonTypeIdsFor(primary, allowedWagonTypes);
const perItemBulk =
Number(primary.bulkTotalWeightTons ?? 0) > 0 &&
Number(primary.cargoTotalWeightVgm ?? 0) > 0;
const useSmart =
bookings.length === 1 &&
primary.freightType === "BULK" &&
!perItemBulk &&
wagonTypeIds.length > 0;
const smart = useSmart
? this.smartBulkNeed(
primary,
wagonDims,
stock,
leg,
this.scarcityRankForPool([primary], allowedWagonTypes),
wagonTypeIds,
)
: null;
const seated = useSmart
? smart != null && budget.fits(smart.need, leg)
: this.hasWagonStock(stock, wagonTypeIds, need.wagons, leg);
if (!seated) {
throw new ConflictException(
"Train has no free wagons of a type this cargo can ride — payment was not opened",
);
}
for (const b of bookings) await this.reserve(b, scheduleId); for (const b of bookings) await this.reserve(b, scheduleId);
}); });
@@ -2275,6 +2319,7 @@ export class BookingBatchService implements OnModuleInit {
await this.recomputeBulkPriorities(pool, wagonDims); await this.recomputeBulkPriorities(pool, wagonDims);
this.resortPoolByPriority(pool, await this.windowCycleIndexer(schedule)); this.resortPoolByPriority(pool, await this.windowCycleIndexer(schedule));
const units = this.groupConsolidatedPool(pool); const units = this.groupConsolidatedPool(pool);
const scarcityRank = this.scarcityRankForPool(pool, allowedWagonTypes);
let armed = false; let armed = false;
let preempted = false; let preempted = false;
let reservedThisPass = 0; let reservedThisPass = 0;
@@ -2301,17 +2346,34 @@ export class BookingBatchService implements OnModuleInit {
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId); const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes); const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes);
// Abstract room AND real wagons of a type this booking can ride — see // Abstract room AND real wagons of a type this booking can ride — see
// fillRouteDayInternal for why both gates are needed. // fillRouteDayInternal for why both gates are needed. PER_TON bulk
const stocked = this.hasWagonStock(stock, wagonTypeIds, need.wagons, leg); // singles get the smart gate (exact per-type seating at the cargo's
// caps); a booking is only reserved — and only ever invoiced — when
// that seating is proven against the train's actual free wagons.
const perItemBulk =
Number(booking.bulkTotalWeightTons ?? 0) > 0 &&
Number(booking.cargoTotalWeightVgm ?? 0) > 0;
const useSmart =
!isPair &&
booking.freightType === "BULK" &&
!perItemBulk &&
wagonTypeIds.length > 0;
const smart = useSmart
? this.smartBulkNeed(booking, wagonDims, stock, leg, scarcityRank, wagonTypeIds)
: null;
const admitted = useSmart
? smart != null && budget.fits(smart.need, leg)
: budget.fits(need, leg) &&
this.hasWagonStock(stock, wagonTypeIds, need.wagons, leg);
// Per-unit fit trace: which axis (wagons/weight/length/stock) admits or rejects. // Per-unit fit trace: which axis (wagons/weight/length/stock) admits or rejects.
this.logger.debug( this.logger.debug(
`[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(need)} ` + `[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(
`roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)} ` + smart?.need ?? need,
`stocked=${stocked}`, )} roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} admitted=${admitted}`,
); );
if (!budget.fits(need, leg) || !stocked) { if (!admitted) {
if (isGov) { if (isGov) {
const freed = await this.preemptForGovernment( const freed = await this.preemptForGovernment(
scheduleId, scheduleId,
@@ -2355,9 +2417,16 @@ export class BookingBatchService implements OnModuleInit {
armed = true; armed = true;
commercialReserved += 1; commercialReserved += 1;
} }
budget.subtract(need, leg); budget.subtract(smart?.need ?? need, leg);
// Hold the physical wagons too — the next unit must not re-count them. // Hold the physical wagons too — the next unit must not re-count them.
stock.consume(wagonTypeIds, need.wagons, leg); // The smart gate holds the exact per-type counts it seated.
if (smart) {
for (const part of smart.perType) {
stock.consume([part.wagonTypeId], part.wagons, leg);
}
} else {
stock.consume(wagonTypeIds, need.wagons, leg);
}
reservedThisPass += 1; reservedThisPass += 1;
} catch (err) { } catch (err) {
this.logger.error( this.logger.error(
@@ -2532,6 +2601,10 @@ export class BookingBatchService implements OnModuleInit {
// Consolidated partners collapse into one atomic unit (both-or-neither); a // Consolidated partners collapse into one atomic unit (both-or-neither); a
// consolidated booking whose partner isn't ready this cycle is skipped. // consolidated booking whose partner isn't ready this cycle is skipped.
const units = this.groupConsolidatedPool(pool); const units = this.groupConsolidatedPool(pool);
// Least-shareable-type-first seating for bulk (see smartBulkNeed): ranked
// once against the whole pool, so what containers will need is known
// before any bulk booking picks its wagons.
const scarcityRank = this.scarcityRankForPool(pool, allowedWagonTypes);
// Batch fill trace: each train's caps + the day pool size at entry. // Batch fill trace: each train's caps + the day pool size at entry.
this.logger.debug( this.logger.debug(
@@ -2555,18 +2628,54 @@ export class BookingBatchService implements OnModuleInit {
// Consolidated pairs share one wagon set; the primary's types stand for both. // Consolidated pairs share one wagon set; the primary's types stand for both.
const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes); const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes);
// PER_TON bulk singles get the smart gate: seated type-by-type at the
// cargo's per-wagon caps, scarcest type first — the count the allocator
// will actually need, not a one-type estimate. Pairs, PER_ITEM and
// unconfigured cargo keep the generic gate (gov preemption and partial
// offers below also still size on the generic `need`).
const perItemBulk =
Number(booking.bulkTotalWeightTons ?? 0) > 0 &&
Number(booking.cargoTotalWeightVgm ?? 0) > 0;
const useSmart =
!isPair &&
booking.freightType === "BULK" &&
!perItemBulk &&
wagonTypeIds.length > 0;
let smart: {
need: Capacity;
perType: Array<{ wagonTypeId: string; wagons: number }>;
} | null = null;
// First train (earliest departure) whose corridor carries this booking's // First train (earliest departure) whose corridor carries this booking's
// leg, still fits it as-is AND physically holds enough wagons of a type the // leg, still fits it as-is AND physically holds enough wagons of a type the
// booking can ride. Both gates matter: abstract room without the right // booking can ride. Both gates matter: abstract room without the right
// wagon type is space the allocator can never turn into a loaded consist. // wagon type is space the allocator can never turn into a loaded consist.
let target = trains.find((t) => { let target: (typeof trains)[number] | undefined;
for (const t of trains) {
const leg = legOn(t); const leg = legOn(t);
return ( if (leg == null) continue;
leg != null && if (useSmart) {
const probe = this.smartBulkNeed(
booking,
wagonDims,
t.stock,
leg,
scarcityRank,
wagonTypeIds,
);
if (probe != null && t.budget.fits(probe.need, leg)) {
smart = probe;
target = t;
break;
}
} else if (
t.budget.fits(need, leg) && t.budget.fits(need, leg) &&
this.hasWagonStock(t.stock, wagonTypeIds, need.wagons, leg) this.hasWagonStock(t.stock, wagonTypeIds, need.wagons, leg)
); ) {
}); target = t;
break;
}
}
// Per-unit trace: chosen train + each train's remaining room on this leg. // Per-unit trace: chosen train + each train's remaining room on this leg.
this.logger.debug( this.logger.debug(
@@ -2645,10 +2754,18 @@ export class BookingBatchService implements OnModuleInit {
target.armed = true; target.armed = true;
commercialReserved += 1; commercialReserved += 1;
} }
target.budget.subtract(need, legOn(target)!); target.budget.subtract(smart?.need ?? need, legOn(target)!);
// Hold the physical wagons too, so the next unit in this pass sees them // Hold the physical wagons too, so the next unit in this pass sees them
// gone — otherwise two bookings both "fit" the same 16 NW5. // gone — otherwise two bookings both "fit" the same 16 NW5. The smart
target.stock.consume(wagonTypeIds, need.wagons, legOn(target)!); // gate holds the EXACT per-type counts it seated (10 PW2 + 17 NW5),
// not a type-blind total drained deepest-first.
if (smart) {
for (const part of smart.perType) {
target.stock.consume([part.wagonTypeId], part.wagons, legOn(target)!);
}
} else {
target.stock.consume(wagonTypeIds, need.wagons, legOn(target)!);
}
target.changed = true; target.changed = true;
reservedThisPass += 1; reservedThisPass += 1;
} catch (err) { } catch (err) {
@@ -2724,6 +2841,21 @@ export class BookingBatchService implements OnModuleInit {
wagonTypeIds: string[] = [], wagonTypeIds: string[] = [],
): Promise<boolean> { ): Promise<boolean> {
if (!this.isSplitEligible(booking, isPair)) return false; if (!this.isSplitEligible(booking, isPair)) return false;
// PER_TON bulk partials are sized on ONE concrete wagon type at the
// cargo's per-wagon cap — sizing on the first type's raw 70T rating
// offered tonnage the wagons could never carry (Perishable caps at
// 20/30T), taking payment for cargo that stalls at allocation.
// ponytail: single-type bulk partials; a multi-type partial (PW2+NW5
// mixed) is the upgrade path if offers come out too small.
const perItemBulk =
Number(booking.bulkTotalWeightTons ?? 0) > 0 &&
Number(booking.cargoTotalWeightVgm ?? 0) > 0;
const cappedBulk =
!isPair &&
booking.freightType === "BULK" &&
!perItemBulk &&
wagonTypeIds.length > 0;
const wagonDims = cappedBulk ? await this.loadWagonDims() : null;
const target = candidates const target = candidates
.map((c) => { .map((c) => {
const leg = c.budget.legOf(booking.originYardId, booking.destinationYardId); const leg = c.budget.legOf(booking.originYardId, booking.destinationYardId);
@@ -2734,12 +2866,43 @@ export class BookingBatchService implements OnModuleInit {
// them NW5" into an offer for 16 — the customer pays for 16 and the // them NW5" into an offer for 16 — the customer pays for 16 and the
// other 4 leave as the usual remainder booking, instead of paying for // other 4 leave as the usual remainder booking, instead of paying for
// 20 and stalling at allocation on wagon 17. // 20 and stalling at allocation on wagon 17.
if (cappedBulk && wagonDims) {
// Types resolved from the id list (join tables), never the pool
// entity's unloaded cargoType.wagonTypes relation — see smartBulkNeed.
const best = [...new Set(wagonTypeIds)]
.map((wagonTypeId) => ({
wagonTypeId,
dims: wagonDims.byWagonTypeId.get(wagonTypeId),
}))
.filter((o): o is { wagonTypeId: string; dims: PerWagonDims } => o.dims != null)
.map((o) => ({
...o,
free: c.stock?.availableFor([o.wagonTypeId], leg) ?? 0,
takePerWagon: bulkTonsPerWagon(
booking.cargoType,
o.wagonTypeId,
o.dims.capacityTons,
),
}))
.filter((o) => o.free > 0 && o.takePerWagon > 0)
.sort((a, b) => b.takePerWagon - a.takePerWagon)[0];
if (!best) return null;
return {
c,
leg,
room: { ...room, wagons: Math.min(room.wagons, best.free) },
seat: {
wagonTypeId: best.wagonTypeId,
perWagon: { ...best.dims, capacityTons: best.takePerWagon },
},
};
}
const physical = wagonTypeIds.length const physical = wagonTypeIds.length
? c.stock?.availableFor(wagonTypeIds, leg) ? c.stock?.availableFor(wagonTypeIds, leg)
: undefined; : undefined;
const wagons = const wagons =
physical == null ? room.wagons : Math.min(room.wagons, physical); physical == null ? room.wagons : Math.min(room.wagons, physical);
return { c, leg, room: { ...room, wagons } }; return { c, leg, room: { ...room, wagons }, seat: undefined };
}) })
.filter((x): x is NonNullable<typeof x> => x != null && x.room.wagons >= 1) .filter((x): x is NonNullable<typeof x> => x != null && x.room.wagons >= 1)
.sort((a, b) => b.room.wagons - a.room.wagons)[0]; .sort((a, b) => b.room.wagons - a.room.wagons)[0];
@@ -2749,10 +2912,15 @@ export class BookingBatchService implements OnModuleInit {
target.c.id, target.c.id,
target.room, target.room,
need, need,
target.seat,
); );
if (!offered) return false; if (!offered) return false;
target.c.budget.subtract(offered, target.leg); target.c.budget.subtract(offered, target.leg);
target.c.stock?.consume(wagonTypeIds, offered.wagons, target.leg); target.c.stock?.consume(
target.seat ? [target.seat.wagonTypeId] : wagonTypeIds,
offered.wagons,
target.leg,
);
target.c.armed = true; target.c.armed = true;
return true; return true;
} }
@@ -2767,6 +2935,12 @@ export class BookingBatchService implements OnModuleInit {
scheduleId: string, scheduleId: string,
budget: Capacity, budget: Capacity,
need: Capacity, need: Capacity,
/**
* Capped-bulk seating (see maybeOfferPartial): the ONE wagon type this
* offer rides, with capacityTons already reduced to the cargo's per-wagon
* cap — so the offered tonnage is what those wagons can really carry.
*/
seat?: { wagonTypeId: string; perWagon: PerWagonDims },
): Promise<Capacity | null> { ): Promise<Capacity | null> {
if (!this.splitService) return null; if (!this.splitService) return null;
// A consolidated booking is already half of a shared wagon — never split it. // A consolidated booking is already half of a shared wagon — never split it.
@@ -2784,8 +2958,14 @@ export class BookingBatchService implements OnModuleInit {
// measured on the booking's REAL wagon type — the same one allocation // measured on the booking's REAL wagon type — the same one allocation
// validates against. Bulk splits ride FULL wagons only: the offer never // validates against. Bulk splits ride FULL wagons only: the offer never
// part-loads its last wagon. // part-loads its last wagon.
const perWagon = this.dimsFor(booking, wagonDims); const perWagon = seat?.perWagon ?? this.dimsFor(booking, wagonDims);
const partial = sizePartialOfferWagons(budget, need.wagons, perWagon, { // With a capped seat, the whole booking's wagon count follows the cap too
// (695T at 30T/wagon = 24, not 10 at the raw rating) — the offer must be a
// strict subset of THAT count.
const wholeWagons = seat
? Math.max(1, Math.ceil(bookingCargoTons(booking) / perWagon.capacityTons))
: need.wagons;
const partial = sizePartialOfferWagons(budget, wholeWagons, perWagon, {
fullWagonsOnly: booking.freightType === "BULK", fullWagonsOnly: booking.freightType === "BULK",
}); });
if (!partial) return null; if (!partial) return null;
@@ -2793,7 +2973,7 @@ export class BookingBatchService implements OnModuleInit {
const sized = await this.splitService.sizeOffer( const sized = await this.splitService.sizeOffer(
booking, booking,
partial.wagons, partial.wagons,
need.wagons, wholeWagons,
perWagon.capacityTons, perWagon.capacityTons,
partial.maxCargoTons, partial.maxCargoTons,
); );
@@ -2832,8 +3012,9 @@ export class BookingBatchService implements OnModuleInit {
* Settle a schedule's reserved bookings. `expireUnpaidUnknownDeadline` decides * Settle a schedule's reserved bookings. `expireUnpaidUnknownDeadline` decides
* how to treat a reservation with no deadline (durable path: leave it; timeout * how to treat a reservation with no deadline (durable path: leave it; timeout
* path: expire it). Consolidated pairs settle atomically: both allocate only * path: expire it). Consolidated pairs settle atomically: both allocate only
* when both paid; if either partner expires, both expire (a half-paid shared * when both paid; when neither paid, both expire. A half-paid pair splits:
* wagon must not ship). Returns whether anything changed. * the paid half keeps the whole wagon, the lapsed half expires and owes the
* cancellation fee (expire()'s pair cascade). Returns whether anything changed.
*/ */
private async settleReserved( private async settleReserved(
scheduleId: string, scheduleId: string,
@@ -2876,8 +3057,10 @@ export class BookingBatchService implements OnModuleInit {
await this.allocate(scheduleId, partner, "paid"); await this.allocate(scheduleId, partner, "paid");
anySettled = true; anySettled = true;
} else if (isExpired(booking) || isExpired(partner)) { } else if (isExpired(booking) || isExpired(partner)) {
// One call is enough: expire()'s pair cascade settles both sides —
// both expire when neither paid; a paid half is rescued (keeps the
// whole wagon) while the lapsed half expires with its fee.
await this.expire(booking); await this.expire(booking);
await this.expire(partner);
anySettled = true; anySettled = true;
} }
continue; continue;
@@ -3816,6 +3999,55 @@ export class BookingBatchService implements OnModuleInit {
booking: Booking, booking: Booking,
reason: "payment" | "no-capacity" = "payment", reason: "payment" | "no-capacity" = "payment",
): Promise<void> { ): Promise<void> {
// Consolidated pair: break the link FIRST, then settle each side singly.
// - neither paid → both expire, no fee.
// - one side paid → the paid half keeps the whole wagon (rescued by the
// paid guard below at no extra cost); the lapsed half expires and owes
// the cancellation fee (the 'partnerLapsed' event opens the fee invoice
// in BookingWagonCancellationService).
// - both paid → nothing to expire; the paid guard rescues.
if (booking.consolidationPartnerId) {
const partnerId = booking.consolidationPartnerId;
const bookingRepo = this.dataSource.getRepository(Booking);
const partnerRow = await bookingRepo.findOne({
where: { id: partnerId },
relations: { company: true },
});
const freshSelf = await bookingRepo.findOne({
where: { id: booking.id },
});
const paidOf = (b: Booking | null) =>
b != null && (b.paymentStatus === "PAID" || b.status === "PAID");
const selfPaid = paidOf(freshSelf);
const partnerPaid = paidOf(partnerRow);
await this.bookingsRepository.clearConsolidationPair(
booking.id,
partnerId,
);
booking.consolidationPartnerId = null;
if (partnerRow) partnerRow.consolidationPartnerId = null;
if (selfPaid && !partnerPaid) {
// Wrong side called first: the lapsed partner is the one that expires
// (with its fee); this paid booking falls through to the rescue below.
if (partnerRow && !["EXPIRED", "CANCELLED"].includes(partnerRow.status)) {
this.eventEmitter?.emit("booking.consolidation.partnerLapsed", {
expiredBookingId: partnerRow.id,
});
await this.expire(partnerRow, reason);
}
} else if (!selfPaid && partnerPaid) {
this.eventEmitter?.emit("booking.consolidation.partnerLapsed", {
expiredBookingId: booking.id,
});
// fall through: this side expires below; the paid partner is untouched.
} else if (!selfPaid && !partnerPaid) {
if (partnerRow && !["EXPIRED", "CANCELLED"].includes(partnerRow.status)) {
await this.expire(partnerRow, reason);
}
}
}
if (!booking.consolidationPartnerId) { if (!booking.consolidationPartnerId) {
const fresh = await this.dataSource const fresh = await this.dataSource
.getRepository(Booking) .getRepository(Booking)
@@ -4097,7 +4329,50 @@ export class BookingBatchService implements OnModuleInit {
// and push once per schedule after the sweep (most unaccepted rows are // and push once per schedule after the sweep (most unaccepted rows are
// unpinned under day-level pooling, so this usually emits nothing). // unpinned under day-level pooling, so this usually emits nothing).
const touchedScheduleIds = new Set<string>(); const touchedScheduleIds = new Set<string>();
const swept = new Set<string>();
for (const booking of unaccepted) { for (const booking of unaccepted) {
if (swept.has(booking.id)) continue;
swept.add(booking.id);
// Consolidated pair: the partner may sit outside this route-day's result
// set (different yards/day/status), so cascade explicitly — an unpaid
// partner expires with this booking; a PAID partner keeps the whole
// wagon and this booking owes the cancellation fee (partnerLapsed).
if (booking.consolidationPartnerId) {
const partner = await this.dataSource.getRepository(Booking).findOne({
where: { id: booking.consolidationPartnerId },
relations: { company: true },
});
await this.bookingsRepository.clearConsolidationPair(
booking.id,
booking.consolidationPartnerId,
);
booking.consolidationPartnerId = null;
if (partner) {
const partnerPaid =
partner.paymentStatus === "PAID" || partner.status === "PAID";
if (partnerPaid) {
this.eventEmitter?.emit("booking.consolidation.partnerLapsed", {
expiredBookingId: booking.id,
});
} else if (!["EXPIRED", "CANCELLED"].includes(partner.status)) {
swept.add(partner.id);
partner.consolidationPartnerId = null;
if (partner.trainScheduleId) touchedScheduleIds.add(partner.trainScheduleId);
await this.bookingsRepository.update(partner.id, {
status: "EXPIRED",
schedulingStatus: "ELIGIBLE",
scheduledDate: null,
} as never);
await this.billing
.expirePayable(Freight.InvoiceSource.Booking, partner.id, "PREPAID")
.catch(() => undefined);
this.notifier.expired(partner);
this.logger.log(
`[BATCH] EXPIRED (unaccepted, with consolidation partner) ${partner.reference}:${partner.id} at doc-review end`,
);
}
}
}
if (booking.trainScheduleId) touchedScheduleIds.add(booking.trainScheduleId); if (booking.trainScheduleId) touchedScheduleIds.add(booking.trainScheduleId);
await this.bookingsRepository.update(booking.id, { await this.bookingsRepository.update(booking.id, {
status: "EXPIRED", status: "EXPIRED",
@@ -4790,6 +5065,8 @@ export class BookingBatchService implements OnModuleInit {
stock.byYardId, stock.byYardId,
budget.stops, budget.stops,
); );
// Wagons staff cut mid-route are not stock past their cut stop.
ledger.debitCutWagons(stock.cutWagons ?? []);
// Debit what is already committed, per boarding yard and wagon type — the // Debit what is already committed, per boarding yard and wagon type — the
// same bookings the corridor budget subtracted. A booking with no resolvable // same bookings the corridor budget subtracted. A booking with no resolvable
// wagon type still occupies steel, so it drains any type at its yard. // wagon type still occupies steel, so it drains any type at its yard.
@@ -4798,12 +5075,34 @@ export class BookingBatchService implements OnModuleInit {
this.loadAllowedWagonTypeIds(), this.loadAllowedWagonTypeIds(),
]); ]);
const anyType = [...stock.remainingByTypeId.keys()]; const anyType = [...stock.remainingByTypeId.keys()];
for (const b of await this.committedBookings(schedule, excludeBookingIds)) { const committed = await this.committedBookings(schedule, excludeBookingIds);
// Debit committed PER_TON bulk the way it was SEATED — per type at the
// cargo's caps, scarcest type first — not a one-type wagon count drained
// deepest-first (which mis-charged 695T Perishable as 24 NW5 when it holds
// 10 PW2 + 17 NW5, so later passes over-counted free PW2 and sold NW5 that
// were already spoken for).
const rank = this.scarcityRankForPool(committed, allowed);
for (const b of committed) {
const typeIds = this.allowedWagonTypeIdsFor(b, allowed); const typeIds = this.allowedWagonTypeIdsFor(b, allowed);
const leg = budget.legForYards(b.originYardId, b.destinationYardId);
const perItemBulk =
Number(b.bulkTotalWeightTons ?? 0) > 0 &&
Number(b.cargoTotalWeightVgm ?? 0) > 0;
if (b.freightType === "BULK" && !perItemBulk && typeIds.length) {
const smart = this.smartBulkNeed(b, wagonDims, ledger, leg, rank, typeIds);
if (smart) {
for (const part of smart.perType) {
ledger.consume([part.wagonTypeId], part.wagons, leg);
}
continue;
}
// Over-committed (stock cannot seat it any more) — drain what exists,
// same as before, so the shortage stays visible to the gates.
}
ledger.consume( ledger.consume(
typeIds.length ? typeIds : anyType, typeIds.length ? typeIds : anyType,
this.wagonsFor(b, wagonDims), this.wagonsFor(b, wagonDims),
budget.legForYards(b.originYardId, b.destinationYardId), leg,
); );
} }
return ledger; return ledger;
@@ -4825,6 +5124,122 @@ export class BookingBatchService implements OnModuleInit {
return stock.availableFor(wagonTypeIds, leg) >= wagonsNeeded; return stock.availableFor(wagonTypeIds, leg) >= wagonsNeeded;
} }
/**
* Scarcity rank over the day pool: how many distinct demand groups (bulk
* cargo types / container types among these bookings) may ride each wagon
* type. The batch seats least-shareable types first, so bulk with a
* bulk-only alternative (PW2) never eats the container-capable stock (NW5)
* that containers cannot substitute.
*/
private scarcityRankForPool(
pool: Booking[],
allowed: {
byCargoTypeId: Map<string, string[]>;
byContainerTypeId: Map<string, string[]>;
},
): Map<string, number> {
const groups = new Map<string, string[]>();
for (const b of pool) {
if (b.freightType === "BULK") {
const cargoTypeId = b.cargoTypeId ?? b.cargoType?.id;
if (cargoTypeId) {
groups.set(`B:${cargoTypeId}`, allowed.byCargoTypeId.get(cargoTypeId) ?? []);
}
} else {
for (const line of b.bookingContainers ?? []) {
const containerTypeId = line.containerTypeId ?? line.containerType?.id;
if (containerTypeId) {
groups.set(
`C:${containerTypeId}`,
allowed.byContainerTypeId.get(containerTypeId) ?? [],
);
}
}
}
}
const rank = new Map<string, number>();
for (const ids of groups.values()) {
for (const id of ids) rank.set(id, (rank.get(id) ?? 0) + 1);
}
return rank;
}
/**
* Cap-aware, scarcity-ordered seating of a PER_TON bulk booking across the
* wagon types this train actually has free on its leg — the same policy the
* wagon planner applies at allocation time (least-shareable type first, each
* wagon filled to the cargo type's per-wagon cap, one booking per wagon).
*
* This is the payment gate's real fit check for bulk: the generic
* `hasWagonStock` sums free wagons across allowed types against a count
* sized on ONE type, so 695T Perishable read "24 wagons needed, 28 free"
* when seating it across 10 PW2 (20T) + NW5 (30T) really takes 27 wagons.
* Returns the exact per-type counts and the three-axis capacity they
* consume, or null when the free stock cannot seat the whole booking.
*/
private smartBulkNeed(
booking: Booking,
wagonDims: WagonDims,
stock: WagonStockLedger,
leg: CorridorLeg,
scarcityRank: Map<string, number>,
/**
* Wagon-type ids this booking may ride, from {@link loadAllowedWagonTypeIds}
* — NEVER from `booking.cargoType.wagonTypes`. The batch pool finders
* deliberately do not join that relation (hot path), so on a pool entity
* it is always empty; resolving through it made every PER_TON bulk booking
* unseatable — no whole fit and no partial offer, silently READY forever
* (the S-2026-00020 / BK-2026-000036 incident).
*/
wagonTypeIds: readonly string[],
): { need: Capacity; perType: Array<{ wagonTypeId: string; wagons: number }> } | null {
const options = [...new Set(wagonTypeIds)]
.map((wagonTypeId) => ({ wagonTypeId, dims: wagonDims.byWagonTypeId.get(wagonTypeId) }))
.filter((o): o is { wagonTypeId: string; dims: PerWagonDims } => o.dims != null)
.map((o) => ({
...o,
free: stock.availableFor([o.wagonTypeId], leg),
takePerWagon: bulkTonsPerWagon(
booking.cargoType,
o.wagonTypeId,
o.dims.capacityTons,
),
}))
.filter((o) => o.free > 0 && o.takePerWagon > 0)
.sort(
(a, b) =>
(scarcityRank.get(a.wagonTypeId) ?? 1) -
(scarcityRank.get(b.wagonTypeId) ?? 1) ||
b.takePerWagon - a.takePerWagon,
);
let remaining = bookingCargoTons(booking);
if (remaining <= 0) return null;
const perType: Array<{ wagonTypeId: string; wagons: number }> = [];
let weightTons = remaining; // gross: cargo plus each seated wagon's tare
let lengthMeters = 0;
let wagons = 0;
for (const option of options) {
if (remaining <= 1e-9) break;
const take = Math.min(option.free, Math.ceil(remaining / option.takePerWagon));
if (take <= 0) continue;
remaining = roundTons(Math.max(0, remaining - take * option.takePerWagon));
wagons += take;
weightTons += take * option.dims.tareWeightTons;
lengthMeters += take * option.dims.lengthMeters;
perType.push({ wagonTypeId: option.wagonTypeId, wagons: take });
}
if (remaining > 1e-9) return null;
return {
need: {
wagons,
weightTons: roundTons(weightTons),
lengthMeters: roundTons(lengthMeters),
},
perType,
};
}
private allowedWagonTypeCache: { private allowedWagonTypeCache: {
byCargoTypeId: Map<string, string[]>; byCargoTypeId: Map<string, string[]>;
byContainerTypeId: Map<string, string[]>; byContainerTypeId: Map<string, string[]>;
@@ -4969,6 +5384,12 @@ export class BookingBatchService implements OnModuleInit {
// wagon serves disjoint legs — capacity freed past an alight yard is real. // wagon serves disjoint legs — capacity freed past an alight yard is real.
const stops = await this.stopsForSchedule(schedule); const stops = await this.stopsForSchedule(schedule);
const budget = new CorridorBudget(stops, limits.base, limits.tolerance); const budget = new CorridorBudget(stops, limits.base, limits.tolerance);
// Wagons staff plan to cut mid-route are gone from every edge past the cut.
// ponytail: the wagon-type stock ledger stays cut-blind; bucket
// builtTrainStock by (yard, reach) if mixed-type cut trains appear.
subtractCutWagons(budget, schedule.plannedWagonCutYards);
// Planned couples add a slot from their couple stop onward.
addCoupledWagons(budget, schedule.plannedWagonCouples);
for (const b of await this.committedBookings(schedule, excludeBookingIds)) { for (const b of await this.committedBookings(schedule, excludeBookingIds)) {
budget.subtract( budget.subtract(
this.needFor(b, wagonDims), this.needFor(b, wagonDims),

View File

@@ -0,0 +1,143 @@
import { Booking } from '../bookings/entities/booking.entity';
import { BookingBatchService } from './booking-batch.service';
import { WagonStockLedger } from './wagon-stock-ledger.util';
/**
* smartBulkNeed math in isolation: the private helpers it touches
* (allowedDimsWithTypes) read only their arguments, so a bare prototype
* instance is enough — no Nest wiring.
*///
describe('BookingBatchService.smartBulkNeed', () => {
const service = Object.create(BookingBatchService.prototype) as BookingBatchService;
const call = (
booking: Booking,
stock: WagonStockLedger,
rank: Map<string, number>,
) =>
(
service as unknown as {
smartBulkNeed: (
b: Booking,
d: unknown,
s: WagonStockLedger,
l: { fromEdge: number; toEdge: number },
r: Map<string, number>,
ids: readonly string[],
) => { need: { wagons: number }; perType: Array<{ wagonTypeId: string; wagons: number }> } | null;
}
).smartBulkNeed(booking, wagonDims, stock, { fromEdge: 0, toEdge: 1 }, rank, allowedIds);
const nw5 = { id: 'wt-nw5', capacityTons: 70 };
const pw2 = { id: 'wt-pw2', capacityTons: 70 };
// Shaped like a BATCH POOL entity: cargoType WITHOUT the wagonTypes
// relation (the pool query never joins it) — allowed types must come from
// the ids parameter, or every pool bulk booking reads as unseatable.
const perishable = {
id: 'cargo-perishable',
tonsPerWagonMap: { [nw5.id]: 30, [pw2.id]: 20 },
};
const allowedIds = [nw5.id, pw2.id];
const wagonDims = {
container: { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 },
bulk: { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 },
byWagonTypeId: new Map([
[nw5.id, { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 }],
[pw2.id, { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 }],
]),
};
const booking = (tons: number): Booking =>
({
id: 'b1',
reference: 'b1',
freightType: 'BULK',
cargoTotalWeightVgm: tons,
cargoTypeId: perishable.id,
cargoType: perishable,
bookingContainers: [],
}) as unknown as Booking;
// Containers compete for NW5 → NW5 rank 2, PW2 rank 1.
const contested = new Map([
[nw5.id, 2],
[pw2.id, 1],
]);
it('seats 695T as 10 PW2 (20T) + 17 NW5 (30T) = 27 wagons, PW2 first', () => {
const stock = new WagonStockLedger(
new Map([
[nw5.id, 18],
[pw2.id, 10],
]),
1,
);
const smart = call(booking(695), stock, contested);
expect(smart).not.toBeNull();
expect(smart!.need.wagons).toBe(27);
expect(smart!.perType).toEqual([
{ wagonTypeId: pw2.id, wagons: 10 },
{ wagonTypeId: nw5.id, wagons: 17 },
]);
});
it('returns null when the free stock cannot seat the whole booking', () => {
const stock = new WagonStockLedger(
new Map([
[nw5.id, 5],
[pw2.id, 10],
]),
1,
);
// 10×20 + 5×30 = 350T < 695T.
expect(call(booking(695), stock, contested)).toBeNull();
});
it('S-2026-00020 shape: 42x40ft eat the NW5, 200T bulk still seats on the 10 coupled PW2', () => {
// The staging complaint: a built train of 42 NW5 + 10 PW2, containers
// hold every NW5, and a bulk booking sits in "Ready for batch" while the
// PW2 ride empty. The chain: committed containers drain NW5 from the
// ledger (their types cannot touch PW2), then the smart gate must seat
// 200T of Perishable on the 10 PW2 at the 20T cap.
const stock = new WagonStockLedger(
new Map([
[nw5.id, 42],
[pw2.id, 10],
]),
2, // DCT -> Dire -> GMP: two edges
);
// Committed container booking rides Dire->GMP (edge 1) on 42 NW5 —
// container-capable types only, exactly how stockLedgerFor debits it.
stock.consume([nw5.id], 42, { fromEdge: 1, toEdge: 2 });
expect(stock.availableFor([nw5.id], { fromEdge: 0, toEdge: 2 })).toBe(0);
expect(stock.availableFor([pw2.id], { fromEdge: 0, toEdge: 2 })).toBe(10);
const smart = (
service as unknown as {
smartBulkNeed: (
b: Booking,
d: unknown,
s: WagonStockLedger,
l: { fromEdge: number; toEdge: number },
r: Map<string, number>,
ids: readonly string[],
) => { need: { wagons: number }; perType: Array<{ wagonTypeId: string; wagons: number }> } | null;
}
).smartBulkNeed(booking(200), wagonDims, stock, { fromEdge: 0, toEdge: 2 }, contested, allowedIds);
expect(smart).not.toBeNull();
expect(smart!.perType).toEqual([{ wagonTypeId: pw2.id, wagons: 10 }]);
});
it('uncontested types fall back to biggest per-cargo take (fewest wagons)', () => {
const stock = new WagonStockLedger(
new Map([
[nw5.id, 10],
[pw2.id, 10],
]),
1,
);
const even = new Map([
[nw5.id, 1],
[pw2.id, 1],
]);
const smart = call(booking(60), stock, even);
expect(smart!.perType).toEqual([{ wagonTypeId: nw5.id, wagons: 2 }]);
});
});

View File

@@ -1,6 +1,7 @@
import { import {
autoFillPlacements, autoFillPlacements,
findMissingContainerNumberIssues, findMissingContainerNumberIssues,
occupiedTeuPerEdgeBySlot,
type ContainerUnitForPlacement, type ContainerUnitForPlacement,
} from './container-placement.util'; } from './container-placement.util';
@@ -27,14 +28,15 @@ describe('container-placement.util', () => {
]; ];
it('auto-fills placements across slots', () => { it('auto-fills placements across slots', () => {
const placements = autoFillPlacements(units, [1, 2]); const { placements, overflow } = autoFillPlacements(units, [1, 2]);
expect(placements).toHaveLength(2); expect(placements).toHaveLength(2);
expect(overflow).toHaveLength(0);
expect(placements[0].sequenceNo).toBe(1); expect(placements[0].sequenceNo).toBe(1);
expect(placements[1].sequenceNo).toBe(2); expect(placements[1].sequenceNo).toBe(2);
}); });
it('reports missing container numbers only when placement is empty', () => { it('reports missing container numbers only when placement is empty', () => {
const placements = autoFillPlacements(units, [1, 2]); const { placements } = autoFillPlacements(units, [1, 2]);
const issues = findMissingContainerNumberIssues(units, placements); const issues = findMissingContainerNumberIssues(units, placements);
expect(issues).toHaveLength(0); expect(issues).toHaveLength(0);
expect(placements[1].containerNumber).toMatch(/^TBD-/); expect(placements[1].containerNumber).toMatch(/^TBD-/);
@@ -53,7 +55,84 @@ describe('container-placement.util', () => {
containerNumber: null, containerNumber: null,
}, },
]; ];
const placements = autoFillPlacements(single, [1]); const { placements } = autoFillPlacements(single, [1]);
expect(placements[0].containerNumber).toBe('TBD-BK-2026-000033-1'); expect(placements[0].containerNumber).toBe('TBD-BK-2026-000033-1');
}); });
const ft40 = (
bookingId: string,
i: number,
leg?: { from: number; to: number },
): ContainerUnitForPlacement => ({
bookingId,
bookingReference: bookingId,
bookingContainerId: `${bookingId}-line`,
unitIndex: i,
label: `${bookingId} · ${i + 1} · 40GP`,
teuSlots: 2,
sizeFt: 40,
containerNumber: `CNT${bookingId}${i}`,
leg,
});
it('never clamps overflow onto the last slot — returns it instead', () => {
// 3 × 40ft, 2 slots. The old walk piled unit 3 onto slot #2 and let the
// validator reject it once per container ("Wagon #42…", the reported bug).
const three = [ft40('A', 0), ft40('A', 1), ft40('A', 2)];
const { placements, overflow } = autoFillPlacements(three, [1, 2]);
expect(placements).toHaveLength(2);
expect(overflow).toHaveLength(1);
expect(placements.every((p) => p.sequenceNo === 1 || p.sequenceNo === 2)).toBe(true);
});
it('leg-aware: disjoint-leg 40fts share one wagon (the staging case)', () => {
// 2 slots riding the whole 2-edge route. Leg-blind fill fits only two of
// these four 40fts; per-edge TEU fits all four — two per wagon, one per leg.
const slots = [
{ sequenceNo: 1, from: 0, to: 2 },
{ sequenceNo: 2, from: 0, to: 2 },
];
const four = [
ft40('LEG1', 0, { from: 0, to: 1 }),
ft40('LEG1', 1, { from: 0, to: 1 }),
ft40('LEG2', 0, { from: 1, to: 2 }),
ft40('LEG2', 1, { from: 1, to: 2 }),
];
const { placements, overflow } = autoFillPlacements(four, slots, new Map(), 2);
expect(overflow).toHaveLength(0);
expect(placements).toHaveLength(4);
});
it('same-leg 40fts still never share a wagon', () => {
const slots = [{ sequenceNo: 1, from: 0, to: 2 }];
const two = [ft40('X', 0, { from: 0, to: 1 }), ft40('X', 1, { from: 0, to: 1 })];
const { placements, overflow } = autoFillPlacements(two, slots, new Map(), 2);
expect(placements).toHaveLength(1);
expect(overflow).toHaveLength(1);
});
it('respects per-edge occupied TEU from caller-provided placements', () => {
const slots = [{ sequenceNo: 1, from: 0, to: 2 }];
const provided = [{ bookingContainerId: 'P-line', unitIndex: 0, sequenceNo: 1 }];
const providedUnit = ft40('P', 0, { from: 0, to: 1 });
const occupied = occupiedTeuPerEdgeBySlot(provided, [providedUnit], 2);
// Edge 0 is full on slot 1; an edge-0 unit overflows, an edge-1 unit fits.
const edge0 = autoFillPlacements([ft40('Q', 0, { from: 0, to: 1 })], slots, occupied, 2);
expect(edge0.overflow).toHaveLength(1);
const edge1 = autoFillPlacements([ft40('Q', 0, { from: 1, to: 2 })], slots, occupied, 2);
expect(edge1.overflow).toHaveLength(0);
expect(edge1.placements[0].sequenceNo).toBe(1);
});
it('a unit never lands on a slot that does not ride its leg', () => {
const slots = [{ sequenceNo: 1, from: 0, to: 1 }]; // alights at stop 1
const { placements, overflow } = autoFillPlacements(
[ft40('Y', 0, { from: 1, to: 2 })],
slots,
new Map(),
2,
);
expect(placements).toHaveLength(0);
expect(overflow).toHaveLength(1);
});
}); });

View File

@@ -9,6 +9,18 @@ export type ContainerUnitForPlacement = {
teuSlots?: number; teuSlots?: number;
sizeFt?: number; sizeFt?: number;
containerNumber?: string | null; containerNumber?: string | null;
/**
* Stop-index span this unit's BOOKING rides (leg-aware trains). Omitted →
* the whole route, which is exact for single-leg schedules.
*/
leg?: { from: number; to: number };
};
/** A container-capable wagon slot with the stop-index span it physically rides. */
export type ContainerSlotForPlacement = {
sequenceNo: number;
from: number;
to: number;
}; };
export function placeholderContainerNumber(unit: ContainerUnitForPlacement): string { export function placeholderContainerNumber(unit: ContainerUnitForPlacement): string {
@@ -25,51 +37,118 @@ export function resolveContainerNumber(unit: ContainerUnitForPlacement): string
return trimmed || placeholderContainerNumber(unit); return trimmed || placeholderContainerNumber(unit);
} }
/**
* Auto-place container units onto the plan's container slots.
*
* TEU is tracked PER CORRIDOR EDGE, because that is how the planner and the
* validator count it: a wagon whose 40ft alights at Dire Dawa has both TEU
* free again for a 40ft boarding there. The old whole-route walk believed a
* wagon was full after one 40ft on ANY leg, ran out of slots on a leg-sharing
* train, and — worse — CLAMPED every leftover unit onto the last slot. That
* produced placements the validator then rejected one by one ("Wagon #42
* cannot fit another 40FT… total weight 560T"), a wall of errors for what is
* really one condition.
*
* Units that genuinely fit nowhere are returned in `overflow` — never
* force-placed. The caller owns turning that into ONE honest message.
*
* `containerSlots` may be plain sequence numbers (whole-route spans — exact
* for single-leg schedules and identical to the old behaviour) or spans.
*/
export function autoFillPlacements( export function autoFillPlacements(
units: ContainerUnitForPlacement[], units: ContainerUnitForPlacement[],
containerSlots: number[], containerSlots: ReadonlyArray<number | ContainerSlotForPlacement>,
/** /**
* TEU already taken per slot sequenceNo by placements the caller supplied. * TEU already taken per slot sequenceNo by placements the caller supplied.
* Without it a partial auto-fill restarted at wagon #1 and stacked a second * A plain number occupies every edge of the slot; an array is per-edge.
* 40ft onto a wagon another booking's placement had already filled.
*/ */
occupiedTeuBySlot: ReadonlyMap<number, number> = new Map(), occupiedTeuBySlot: ReadonlyMap<number, number | readonly number[]> = new Map(),
): ContainerPlacementInput[] { edgeCount = 1,
if (!units.length || !containerSlots.length) return []; ): { placements: ContainerPlacementInput[]; overflow: ContainerUnitForPlacement[] } {
const edges = Math.max(1, edgeCount);
const slots: ContainerSlotForPlacement[] = containerSlots.map((s) =>
typeof s === 'number' ? { sequenceNo: s, from: 0, to: edges } : s,
);
const placements: ContainerPlacementInput[] = []; const placements: ContainerPlacementInput[] = [];
const overflow: ContainerUnitForPlacement[] = [];
if (!units.length) return { placements, overflow };
if (!slots.length) return { placements, overflow: [...units] };
const MAX_TEU_PER_WAGON = 2; const MAX_TEU_PER_WAGON = 2;
let currentSlotIndex = 0; const used = new Map<number, number[]>();
let teuInCurrentSlot = occupiedTeuBySlot.get(containerSlots[0]!) ?? 0; const usedRow = (sequenceNo: number): number[] => {
let row = used.get(sequenceNo);
if (!row) {
const seed = occupiedTeuBySlot.get(sequenceNo) ?? 0;
row =
typeof seed === 'number'
? new Array<number>(edges).fill(seed)
: Array.from({ length: edges }, (_, e) => seed[e] ?? 0);
used.set(sequenceNo, row);
}
return row;
};
const legOf = (unit: ContainerUnitForPlacement): { from: number; to: number } => {
const leg = unit.leg;
if (!leg || leg.from < 0 || leg.to > edges || leg.from >= leg.to) {
return { from: 0, to: edges };
}
return leg;
};
for (const unit of units) { for (const unit of units) {
const teu = unit.teuSlots ?? (unit.sizeFt && unit.sizeFt >= 40 ? 2 : 1); const teu = unit.teuSlots ?? (unit.sizeFt && unit.sizeFt >= 40 ? 2 : 1);
const leg = legOf(unit);
while ( const slot = slots.find((s) => {
teuInCurrentSlot > 0 && if (s.from > leg.from || leg.to > s.to) return false;
teuInCurrentSlot + teu > MAX_TEU_PER_WAGON && const row = usedRow(s.sequenceNo);
currentSlotIndex < containerSlots.length - 1 for (let e = leg.from; e < leg.to; e += 1) {
) { if ((row[e] ?? 0) + teu > MAX_TEU_PER_WAGON) return false;
currentSlotIndex += 1; }
teuInCurrentSlot = occupiedTeuBySlot.get(containerSlots[currentSlotIndex]!) ?? 0; return true;
});
if (!slot) {
overflow.push(unit);
continue;
} }
const row = usedRow(slot.sequenceNo);
const sequenceNo = for (let e = leg.from; e < leg.to; e += 1) row[e] = (row[e] ?? 0) + teu;
containerSlots[Math.min(currentSlotIndex, containerSlots.length - 1)] ??
containerSlots[containerSlots.length - 1] ??
containerSlots[0];
placements.push({ placements.push({
bookingContainerId: unit.bookingContainerId, bookingContainerId: unit.bookingContainerId,
unitIndex: unit.unitIndex, unitIndex: unit.unitIndex,
sequenceNo, sequenceNo: slot.sequenceNo,
containerNumber: resolveContainerNumber(unit), containerNumber: resolveContainerNumber(unit),
}); });
teuInCurrentSlot += teu;
} }
return placements; return { placements, overflow };
}
/**
* Per-edge TEU consumed by the given placements, using each placed unit's own
* leg — the seed `autoFillPlacements` needs on a leg-aware train.
*/
export function occupiedTeuPerEdgeBySlot(
placements: ReadonlyArray<{ bookingContainerId: string; unitIndex: number; sequenceNo: number }>,
units: ContainerUnitForPlacement[],
edgeCount: number,
): Map<number, number[]> {
const edges = Math.max(1, edgeCount);
const unitByKey = new Map(units.map((u) => [`${u.bookingContainerId}:${u.unitIndex}`, u]));
const out = new Map<number, number[]>();
for (const p of placements) {
const unit = unitByKey.get(`${p.bookingContainerId}:${p.unitIndex}`);
const teu = unit ? (unit.teuSlots ?? (unit.sizeFt && unit.sizeFt >= 40 ? 2 : 1)) : 1;
const leg =
unit?.leg && unit.leg.from >= 0 && unit.leg.to <= edges && unit.leg.from < unit.leg.to
? unit.leg
: { from: 0, to: edges };
const row = out.get(p.sequenceNo) ?? new Array<number>(edges).fill(0);
for (let e = leg.from; e < leg.to; e += 1) row[e] = (row[e] ?? 0) + teu;
out.set(p.sequenceNo, row);
}
return out;
} }
/** TEU per slot sequenceNo consumed by the given placements. */ /** TEU per slot sequenceNo consumed by the given placements. */

View File

@@ -1,6 +1,7 @@
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import type { Response } from "express"; import type { Response } from "express";
import type { AuthUserPayload } from "../../../common/resolve-auth-user-id"; import type { AuthUserPayload } from "../../../common/resolve-auth-user-id";
import { PaginationQueryDto } from "../../../common/dto/pagination-query.dto";
import { UserTradeAccessService } from "../../user-trade-access/user-trade-access.service"; import { UserTradeAccessService } from "../../user-trade-access/user-trade-access.service";
import { resolveAuthUserId } from "../../../common/resolve-auth-user-id"; import { resolveAuthUserId } from "../../../common/resolve-auth-user-id";
@@ -16,7 +17,9 @@ import {
TrainSchedulingCancel, TrainSchedulingCancel,
TrainSchedulingCreate, TrainSchedulingCreate,
TrainSchedulingEditTrainNumber, TrainSchedulingEditTrainNumber,
TrainSchedulingLoad,
TrainSchedulingReschedule, TrainSchedulingReschedule,
TrainSchedulingUnload,
TrainSchedulingRulesManage, TrainSchedulingRulesManage,
TrainSchedulingUpdate, TrainSchedulingUpdate,
TrainSchedulingView, TrainSchedulingView,
@@ -206,7 +209,7 @@ export class TrainSchedulingController {
@TrainSchedulingView() @TrainSchedulingView()
@ApiOperation({ @ApiOperation({
summary: summary:
"Schedule wagon yard plan: where THIS departure boards each consist wagon vs where it physically stands, per-stop totals, locked wagons", "Schedule wagon yard plan: where THIS departure boards and cuts each consist wagon vs where it physically stands, per-stop totals, locked wagons",
}) })
getScheduleWagonYards(@Param("id", ParseUUIDPipe) id: string) { getScheduleWagonYards(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getScheduleWagonYards(id); return this.trainSchedulingService.getScheduleWagonYards(id);
@@ -216,13 +219,13 @@ export class TrainSchedulingController {
@TrainSchedulingUpdate() @TrainSchedulingUpdate()
@ApiOperation({ @ApiOperation({
summary: summary:
"Re-plan the yard this departure boards wagons from (schedule-only; physical yards untouched, dispatch requires alignment)", "Re-plan the yard this departure boards wagons from and/or cuts them at (schedule-only; physical yards untouched, dispatch requires alignment)",
}) })
updateScheduleWagonYards( updateScheduleWagonYards(
@Param("id", ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateScheduleWagonYardsDto, @Body() dto: UpdateScheduleWagonYardsDto,
) { ) {
return this.trainSchedulingService.updateScheduleWagonYards(id, dto.moves); return this.trainSchedulingService.updateScheduleWagonYards(id, dto);
} }
@Post("schedules/:id/adjust-consist") @Post("schedules/:id/adjust-consist")
@@ -243,14 +246,27 @@ export class TrainSchedulingController {
); );
} }
@Get("schedules/:id/phase")
@TrainSchedulingView()
@ApiOperation({
summary:
"Lightweight polling heartbeat: the schedule's status, booking-window phase and deadlines plus its updated_at — one row, no joins, so clients can poll cheaply and refetch the full detail only when something actually changed",
})
getSchedulePhase(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getSchedulePhase(id);
}
@Get("schedules/:id/history") @Get("schedules/:id/history")
@TrainSchedulingView() @TrainSchedulingView()
@ApiOperation({ @ApiOperation({
summary: summary:
"Unified change history for a schedule: wagon consist adjustments (add/remove/switch, with the stop they happened at) merged with booking composition removals, newest first", "Unified change history for a schedule: wagon consist adjustments (add/remove/switch, with the stop they happened at) merged with booking composition removals, newest first",
}) })
getScheduleHistory(@Param("id", ParseUUIDPipe) id: string) { getScheduleHistory(
return this.trainSchedulingService.getScheduleHistory(id); @Param("id", ParseUUIDPipe) id: string,
@Query() query: PaginationQueryDto,
) {
return this.trainSchedulingService.getScheduleHistory(id, query);
} }
@Get("bookable-schedules") @Get("bookable-schedules")
@@ -598,7 +614,7 @@ export class TrainSchedulingController {
} }
@Post("schedules/:id/bookings/:bookingId/load") @Post("schedules/:id/bookings/:bookingId/load")
@TrainSchedulingUpdate() @TrainSchedulingLoad()
@ApiOperation({ @ApiOperation({
summary: summary:
"Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard)", "Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard)",
@@ -611,7 +627,7 @@ export class TrainSchedulingController {
} }
@Post("schedules/:id/bookings/:bookingId/unload") @Post("schedules/:id/bookings/:bookingId/unload")
@TrainSchedulingUpdate() @TrainSchedulingUnload()
@ApiOperation({ @ApiOperation({
summary: summary:
"Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival", "Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival",
@@ -624,7 +640,7 @@ export class TrainSchedulingController {
} }
@Post("schedules/:id/intercity/:bookingId/load") @Post("schedules/:id/intercity/:bookingId/load")
@TrainSchedulingUpdate() @TrainSchedulingLoad()
@ApiOperation({ @ApiOperation({
summary: "Confirm intercity cargo loaded (train must be at the booking's origin yard)", summary: "Confirm intercity cargo loaded (train must be at the booking's origin yard)",
}) })
@@ -636,7 +652,7 @@ export class TrainSchedulingController {
} }
@Post("schedules/:id/intercity/:bookingId/unload") @Post("schedules/:id/intercity/:bookingId/unload")
@TrainSchedulingUpdate() @TrainSchedulingUnload()
@ApiOperation({ @ApiOperation({
summary: summary:
"Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)", "Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)",

View File

@@ -1,4 +1,11 @@
import { Capacity, CorridorBudget } from './corridor-capacity.util'; import {
addCoupledWagons,
Capacity,
CorridorBudget,
orientStopsToSchedule,
stopYardsFor,
subtractCutWagons,
} from './corridor-capacity.util';
import { sizePartialOfferWagons } from './train-capacity.util'; import { sizePartialOfferWagons } from './train-capacity.util';
describe('corridor-capacity.util — overage tolerance', () => { describe('corridor-capacity.util — overage tolerance', () => {
@@ -118,3 +125,135 @@ describe('corridor-capacity.util — overage tolerance', () => {
}); });
}); });
}); });
describe('corridor-capacity.util — subtractCutWagons', () => {
const stops = ['a', 'b', 'c', 'd'];
const wagonsOnly: Capacity = {
wagons: 53,
weightTons: Number.POSITIVE_INFINITY,
lengthMeters: Number.POSITIVE_INFINITY,
};
// 10 wagons cut at b, 13 cut at c, 30 ride through to d.
const cutPlan = Object.fromEntries([
...Array.from({ length: 10 }, (_, i) => [`w-b-${i}`, 'b']),
...Array.from({ length: 13 }, (_, i) => [`w-c-${i}`, 'c']),
]);
const remaining = (budget: CorridorBudget, from: string, to: string): number =>
budget.remainingFor(budget.legOf(from, to)!).wagons;
it('debits each cut wagon from every edge at/after its cut stop', () => {
const budget = new CorridorBudget(stops, wagonsOnly);
subtractCutWagons(budget, cutPlan);
expect(remaining(budget, 'a', 'b')).toBe(53);
expect(remaining(budget, 'a', 'c')).toBe(43);
expect(remaining(budget, 'b', 'c')).toBe(43);
expect(remaining(budget, 'a', 'd')).toBe(30);
expect(remaining(budget, 'c', 'd')).toBe(30);
});
it('stacks with per-booking subtraction on overlapping edges', () => {
const budget = new CorridorBudget(stops, wagonsOnly);
subtractCutWagons(budget, cutPlan);
budget.subtract({ wagons: 5, weightTons: 0, lengthMeters: 0 }, budget.legOf('a', 'd')!);
expect(remaining(budget, 'a', 'b')).toBe(48);
expect(remaining(budget, 'c', 'd')).toBe(25);
});
it('ignores cut yards off the corridor and at the destination, and a missing plan', () => {
const budget = new CorridorBudget(stops, wagonsOnly);
subtractCutWagons(budget, { 'w-1': 'elsewhere', 'w-2': 'd' });
subtractCutWagons(budget, null);
subtractCutWagons(budget, undefined);
expect(remaining(budget, 'a', 'd')).toBe(53);
});
it('works identically on an export-direction corridor — pure index math', () => {
// Export runs the other way geographically (Kality → Mojo → Doraleh); the
// stop LIST still runs origin→destination, so a cut at Mojo debits every
// edge from Mojo to Doraleh. Nothing in the math is import-specific.
const exportStops = ['kality', 'mojo', 'doraleh'];
const budget = new CorridorBudget(exportStops, wagonsOnly);
subtractCutWagons(budget, { 'w-1': 'mojo', 'w-2': 'mojo' });
expect(remaining(budget, 'kality', 'mojo')).toBe(53);
expect(remaining(budget, 'mojo', 'doraleh')).toBe(51);
expect(remaining(budget, 'kality', 'doraleh')).toBe(51);
});
});
describe('corridor-capacity.util — stop orientation and fallback', () => {
it('keeps a stop list that already runs origin→destination', () => {
expect(orientStopsToSchedule(['a', 'b', 'c'], 'a', 'c')).toEqual(['a', 'b', 'c']);
});
it('reverses a route traversed backwards (return-leg reuse) so cuts still land', () => {
// Milestones stored Doraleh→Mojo→Kality (the import route), reused by an
// export schedule Kality→Doraleh: without orientation every legOf() would
// return null and every cut silently no-op.
const oriented = orientStopsToSchedule(
['doraleh', 'mojo', 'kality'],
'kality',
'doraleh',
);
expect(oriented).toEqual(['kality', 'mojo', 'doraleh']);
const budget = new CorridorBudget(oriented, {
wagons: 10,
weightTons: Number.POSITIVE_INFINITY,
lengthMeters: Number.POSITIVE_INFINITY,
});
subtractCutWagons(budget, { w: 'mojo' });
expect(budget.remainingFor(budget.legOf('mojo', 'doraleh')!).wagons).toBe(9);
});
it('leaves a partially mismatched list untouched (unknown data keeps old behavior)', () => {
expect(orientStopsToSchedule(['x', 'y', 'z'], 'a', 'c')).toEqual(['x', 'y', 'z']);
});
it('stopYardsFor orients a backwards milestone list to the schedule endpoints', () => {
expect(stopYardsFor(['c', 'b', 'a'], 'a', 'c')).toEqual(['a', 'b', 'c']);
});
it('stopYardsFor keeps a single stray milestone as a middle stop', () => {
// Must agree with stopYardsForSchedule/mapScheduleStops: a one-milestone
// route offers that stop for cuts, in capacity AND validation alike.
expect(stopYardsFor(['m'], 'a', 'c')).toEqual(['a', 'm', 'c']);
expect(stopYardsFor([], 'a', 'c')).toEqual(['a', 'c']);
expect(stopYardsFor(null, 'a', 'c')).toEqual(['a', 'c']);
});
});
describe('corridor-capacity.util — addCoupledWagons', () => {
const stops = ['a', 'b', 'c', 'd'];
const wagonsOnly: Capacity = {
wagons: 10,
weightTons: Number.POSITIVE_INFINITY,
lengthMeters: Number.POSITIVE_INFINITY,
};
const remaining = (budget: CorridorBudget, from: string, to: string): number =>
budget.remainingFor(budget.legOf(from, to)!).wagons;
it('credits every edge at/after the couple stop', () => {
const budget = new CorridorBudget(stops, wagonsOnly);
addCoupledWagons(budget, { 'w-1': 'a', 'w-2': 'c' });
expect(remaining(budget, 'a', 'b')).toBe(11); // origin couple rides everything
expect(remaining(budget, 'b', 'c')).toBe(11);
expect(remaining(budget, 'c', 'd')).toBe(12); // + the c-coupled wagon
});
it('nets against cuts on the same budget', () => {
const budget = new CorridorBudget(stops, wagonsOnly);
subtractCutWagons(budget, { 'w-cut': 'c' });
addCoupledWagons(budget, { 'w-new': 'c' });
expect(remaining(budget, 'a', 'c')).toBe(10);
expect(remaining(budget, 'c', 'd')).toBe(10); // cut 1, couple +1
expect(remaining(budget, 'a', 'd')).toBe(10);
});
it('ignores off-corridor and destination couple yards, and a missing plan', () => {
const budget = new CorridorBudget(stops, wagonsOnly);
addCoupledWagons(budget, { 'w-1': 'elsewhere', 'w-2': 'd' });
addCoupledWagons(budget, null);
addCoupledWagons(budget, undefined);
expect(remaining(budget, 'a', 'd')).toBe(10);
});
});

View File

@@ -48,9 +48,38 @@ export function capacityFits(need: Capacity, budget: Capacity): boolean {
} }
/** /**
* Ordered stop yard ids for a schedule. Route milestones (already ordered by * Orient a milestone-derived stop list to THIS schedule's endpoints.
* sequence) when there are at least two; otherwise the schedule's own *
* origin/destination pair — the legacy two-stop pseudo-route. * Milestones run in the route's own direction (import and export routes each
* carry their own ordered sequence, so normally nothing changes). But a
* schedule pointed at a route traversed BACKWARDS (return-leg reuse) would
* otherwise silently break every index-based consumer — `legOf` returns null,
* `subtractCutWagons` no-ops, capacity oversells with zero signal. When the
* list plainly runs destination→origin, reverse it; anything else is left
* untouched (unknown data keeps today's behavior).
*/
export function orientStopsToSchedule(
stops: string[],
originStationId: string,
destinationStationId: string,
): string[] {
if (
stops.length >= 2 &&
stops[0] !== originStationId &&
stops[0] === destinationStationId &&
stops[stops.length - 1] === originStationId
) {
return [...stops].reverse();
}
return stops;
}
/**
* Ordered stop yard ids for a schedule. Route milestones (ordered by
* sequence, oriented to the schedule's own endpoints — import and export
* both) when there are at least two; otherwise the schedule's own
* origin/destination pair around any stray milestone, so a one-milestone
* route keeps its middle stop (same shape as `stopYardsForSchedule`).
*/ */
export function stopYardsFor( export function stopYardsFor(
milestoneYardIdsInOrder: string[] | null | undefined, milestoneYardIdsInOrder: string[] | null | undefined,
@@ -58,9 +87,60 @@ export function stopYardsFor(
destinationStationId: string, destinationStationId: string,
): string[] { ): string[] {
if (milestoneYardIdsInOrder && milestoneYardIdsInOrder.length >= 2) { if (milestoneYardIdsInOrder && milestoneYardIdsInOrder.length >= 2) {
return milestoneYardIdsInOrder; return orientStopsToSchedule(
milestoneYardIdsInOrder,
originStationId,
destinationStationId,
);
}
const raw = [
originStationId,
...(milestoneYardIdsInOrder ?? []),
destinationStationId,
];
const unique: string[] = [];
for (const yardId of raw) {
if (yardId && !unique.includes(yardId)) unique.push(yardId);
}
return unique;
}
/**
* Debit the corridor for wagons staff cut mid-route: each cut wagon is gone
* from every edge at/after its cut stop ([cut, destination)). A cut yard not
* on this corridor — or equal to the destination — is ignored; validation in
* updateScheduleWagonYards owns rejecting it, and a fullLeg() fallback here
* would wrongly zero the whole route.
*/
export function subtractCutWagons(
budget: CorridorBudget,
cutPlan: Record<string, string> | null | undefined,
): void {
if (!cutPlan) return;
const destination = budget.stops[budget.stops.length - 1];
for (const cutYardId of Object.values(cutPlan)) {
const leg = budget.legOf(cutYardId, destination);
if (leg) budget.subtract({ wagons: 1, weightTons: 0, lengthMeters: 0 }, leg);
}
}
/**
* Credit the corridor for LOOSE wagons the schedule plans to COUPLE onto the
* train mid-route: each coupled wagon adds a slot on every edge at/after its
* couple stop ([couple, destination)). A couple yard not on the corridor —
* or equal to the destination — is ignored; updateScheduleWagonYards owns
* rejecting it.
*/
export function addCoupledWagons(
budget: CorridorBudget,
couplePlan: Record<string, string> | null | undefined,
): void {
if (!couplePlan) return;
const destination = budget.stops[budget.stops.length - 1];
for (const coupleYardId of Object.values(couplePlan)) {
const leg = budget.legOf(coupleYardId, destination);
if (leg) budget.add({ wagons: 1, weightTons: 0, lengthMeters: 0 }, leg);
} }
return [originStationId, destinationStationId];
} }
/** Overage a locomotive may absorb beyond its base caps. */ /** Overage a locomotive may absorb beyond its base caps. */

View File

@@ -1,13 +1,57 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { ArrayMaxSize, IsArray, IsUUID, ValidateNested } from 'class-validator'; import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsOptional,
IsUUID,
ValidateIf,
ValidateNested,
} from 'class-validator';
export class ScheduleWagonYardMoveDto { export class ScheduleWagonYardMoveDto {
@ApiProperty({ format: 'uuid', description: "Wagon coupled to the schedule's built train." }) @ApiProperty({ format: 'uuid', description: "Wagon coupled to the schedule's built train." })
@IsUUID() @IsUUID()
wagonId!: string; wagonId!: string;
@ApiProperty({ format: 'uuid', description: 'Pickup stop of the route this departure boards the wagon from.' }) @ApiPropertyOptional({
format: 'uuid',
description: 'Pickup stop of the route this departure boards the wagon from. Omit to leave unchanged.',
})
@IsOptional()
@IsUUID()
yardId?: string;
@ApiPropertyOptional({
format: 'uuid',
nullable: true,
description:
'Drop stop this departure CUTS the wagon at (detached, left behind). null clears it — the wagon rides to the destination. Omit to leave unchanged.',
})
@IsOptional()
@ValidateIf((o: ScheduleWagonYardMoveDto) => o.cutYardId !== null)
@IsUUID()
cutYardId?: string | null;
@ApiPropertyOptional({
description:
'true: REAL cut — the built train permanently loses the wagon at its cut yard. false: soft cut (default) — the wagon sits out this trip but stays in the build. Requires a cut yard.',
})
@IsOptional()
@IsBoolean()
realCut?: boolean;
}
export class ScheduleWagonCoupleDto {
@ApiProperty({ format: 'uuid', description: 'Loose wagon (no built train) to couple.' })
@IsUUID()
wagonId!: string;
@ApiProperty({
format: 'uuid',
description: 'Pickup stop the wagon joins the train at. It must physically stand there.',
})
@IsUUID() @IsUUID()
yardId!: string; yardId!: string;
} }
@@ -18,9 +62,32 @@ export class UpdateScheduleWagonYardsDto {
description: description:
'Wagon → planned boarding yard for THIS schedule only. Physical wagon yards are untouched; dispatch requires both to agree.', 'Wagon → planned boarding yard for THIS schedule only. Physical wagon yards are untouched; dispatch requires both to agree.',
}) })
@IsOptional()
@IsArray() @IsArray()
@ArrayMaxSize(500) @ArrayMaxSize(500)
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => ScheduleWagonYardMoveDto) @Type(() => ScheduleWagonYardMoveDto)
moves!: ScheduleWagonYardMoveDto[]; moves?: ScheduleWagonYardMoveDto[];
@ApiPropertyOptional({
type: [ScheduleWagonCoupleDto],
description:
'Loose wagons to plan-couple onto the train at a pickup stop. They join the built train permanently when the trip reaches that stop.',
})
@IsOptional()
@IsArray()
@ArrayMaxSize(100)
@ValidateNested({ each: true })
@Type(() => ScheduleWagonCoupleDto)
couple?: ScheduleWagonCoupleDto[];
@ApiPropertyOptional({
type: [String],
description: 'Wagon ids to remove from the couple plan (before execution).',
})
@IsOptional()
@IsArray()
@ArrayMaxSize(100)
@IsUUID('all', { each: true })
uncouple?: string[];
} }

View File

@@ -0,0 +1,59 @@
import { computeEdgeLoads } from './edge-load.util';
describe('edge-load.util — computeEdgeLoads', () => {
// gmp -> lebu -> mojo -> adama -> dct: 4 edges.
const EDGES = 4;
const wagon = (fromEdge: number, toEdge: number) => ({
fromEdge,
toEdge,
tareTons: 25,
lengthMeters: 17,
});
it('an uncut whole-route consist loads every edge flat', () => {
const loads = computeEdgeLoads(EDGES, [wagon(0, 4), wagon(0, 4)], []);
for (const e of loads) {
expect(e.weightTons).toBe(50);
expect(e.lengthMeters).toBe(34);
}
});
it('a cut frees tare and length on the edges past the cut', () => {
// One wagon cut at mojo (edge index 2): rides edges 0-1 only.
const loads = computeEdgeLoads(EDGES, [wagon(0, 4), wagon(0, 2)], []);
expect(loads[1]).toEqual({ weightTons: 50, lengthMeters: 34 });
expect(loads[2]).toEqual({ weightTons: 25, lengthMeters: 17 });
expect(loads[3]).toEqual({ weightTons: 25, lengthMeters: 17 });
});
it('a couple adds tare and length only from its couple stop', () => {
const loads = computeEdgeLoads(EDGES, [wagon(0, 4), wagon(2, 4)], []);
expect(loads[1]).toEqual({ weightTons: 25, lengthMeters: 17 });
expect(loads[2]).toEqual({ weightTons: 50, lengthMeters: 34 });
});
it('cut-then-couple at the same stop nets to a flat load', () => {
const loads = computeEdgeLoads(EDGES, [wagon(0, 2), wagon(2, 4)], []);
for (const e of loads) {
expect(e.weightTons).toBe(25);
expect(e.lengthMeters).toBe(17);
}
});
it('cargo weighs only the edges of its own leg', () => {
const loads = computeEdgeLoads(
EDGES,
[wagon(0, 4)],
[{ fromEdge: 1, toEdge: 3, weightTons: 60 }],
);
expect(loads[0].weightTons).toBe(25);
expect(loads[1].weightTons).toBe(85);
expect(loads[2].weightTons).toBe(85);
expect(loads[3].weightTons).toBe(25);
});
it('clamps out-of-range spans instead of throwing', () => {
const loads = computeEdgeLoads(EDGES, [wagon(-2, 99)], []);
for (const e of loads) expect(e.weightTons).toBe(25);
});
});

View File

@@ -0,0 +1,50 @@
/**
* Per-corridor-edge physical load of a train: tare + length of the wagons
* spanning each edge, plus the cargo weight riding it. Used to validate that
* a planned mid-route COUPLE keeps every leg within the locomotives' pull
* weight and train length limits — a wagon cut at Mojo frees its tare/length
* on the edges past Mojo, a wagon coupled there adds its own only from there.
*/
export interface EdgeLoad {
weightTons: number;
lengthMeters: number;
}
export interface EdgeWagonSpan {
/** Half-open edge span [fromEdge, toEdge) the wagon physically rides. */
fromEdge: number;
toEdge: number;
tareTons: number;
lengthMeters: number;
}
export interface EdgeCargoLeg {
fromEdge: number;
toEdge: number;
weightTons: number;
}
export function computeEdgeLoads(
edgeCount: number,
wagonSpans: readonly EdgeWagonSpan[],
cargoLegs: readonly EdgeCargoLeg[],
): EdgeLoad[] {
const loads: EdgeLoad[] = Array.from({ length: Math.max(1, edgeCount) }, () => ({
weightTons: 0,
lengthMeters: 0,
}));
const clamp = (edge: number) => Math.min(Math.max(edge, 0), loads.length);
for (const span of wagonSpans) {
for (let e = clamp(span.fromEdge); e < clamp(span.toEdge); e += 1) {
loads[e].weightTons += span.tareTons;
loads[e].lengthMeters += span.lengthMeters;
}
}
for (const cargo of cargoLegs) {
for (let e = clamp(cargo.fromEdge); e < clamp(cargo.toEdge); e += 1) {
loads[e].weightTons += cargo.weightTons;
}
}
return loads;
}

View File

@@ -35,11 +35,34 @@ export type DeferredBookingRow = {
shortage?: BookingWagonShortage | null; 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[] { export function sortBookingsForScheduling(bookings: Booking[]): Booking[] {
return [...bookings].sort((a, b) => { return [...bookings].sort((a, b) => {
const govDiff = Number(Boolean(b.isGovernment)) - Number(Boolean(a.isGovernment)); const govDiff = Number(Boolean(b.isGovernment)) - Number(Boolean(a.isGovernment));
if (govDiff !== 0) return govDiff; 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); const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0);
if (priorityDiff !== 0) return priorityDiff; if (priorityDiff !== 0) return priorityDiff;

View File

@@ -14,6 +14,8 @@ import {
sumWagonsRequired, sumWagonsRequired,
validate20ftContainerRules, validate20ftContainerRules,
validateContainerPlacements, validateContainerPlacements,
validateMixedTrainLimitsPerEdge,
validateWagonCargoExclusivity,
} from './wagon-plan.util'; } from './wagon-plan.util';
const nw5: WagonType = { const nw5: WagonType = {
@@ -222,6 +224,85 @@ describe('wagon-plan.util', () => {
expect(plan[0]?.slotLoadType).toBe('BULK'); expect(plan[0]?.slotLoadType).toBe('BULK');
expect(buildBulkWagonPlan([bulkBooking], cw3)).toHaveLength(1); expect(buildBulkWagonPlan([bulkBooking], cw3)).toHaveLength(1);
}); });
it('never pools two bulk bookings on one wagon', () => {
// 5T + 40T both fit a single 60T CW3 by tonnage — but a wagon with bulk
// takes that one load only, so each booking gets its own wagon.
const small = {
id: 'bulk-5',
reference: 'bulk-5',
freightType: 'BULK',
cargoTotalWeightVgm: 5,
bookingContainers: [],
} as unknown as Booking;
const other = {
id: 'bulk-40',
reference: 'bulk-40',
freightType: 'BULK',
cargoTotalWeightVgm: 40,
bookingContainers: [],
} as unknown as Booking;
const plan = buildBulkWagonPlan([small, other], cw3);
expect(plan).toHaveLength(2);
for (const slot of plan) {
expect(slot.allocations).toHaveLength(1);
}
expect(plan[0]?.allocations[0]?.bookingId).toBe('bulk-5');
expect(plan[1]?.allocations[0]?.bookingId).toBe('bulk-40');
expect(validateWagonCargoExclusivity(plan)).toEqual([]);
});
it('a multi-wagon bulk booking still spreads over its own wagons', () => {
const big = {
id: 'bulk-130',
reference: 'bulk-130',
freightType: 'BULK',
cargoTotalWeightVgm: 130,
bookingContainers: [],
} as unknown as Booking;
const plan = buildBulkWagonPlan([big], cw3);
expect(plan).toHaveLength(3);
expect(plan.map((s) => s.allocations[0]?.allocatedWeightTons)).toEqual([60, 60, 10]);
});
it('flags a wagon mixing bulk with anything else', () => {
const bulkAlloc = {
bookingId: 'b',
bookingReference: 'b',
allocatedWeightTons: 5,
loadType: AllocationLoadType.Bulk,
};
const containerAlloc = {
bookingId: 'c',
bookingReference: 'c',
allocatedWeightTons: 25,
loadType: AllocationLoadType.Container,
};
const slot = (allocations: (typeof bulkAlloc)[]) => ({
sequenceNo: 1,
wagonTypeId: cw3.id,
wagonTypeCode: cw3.code,
capacityTons: 60,
lengthMeters: 14,
tareWeightTons: 24,
assignedWeightTons: 0,
allocations,
});
// bulk + container on one wagon
expect(validateWagonCargoExclusivity([slot([bulkAlloc, containerAlloc])]))
.toHaveLength(1);
// bulk + bulk on one wagon
expect(
validateWagonCargoExclusivity([slot([bulkAlloc, { ...bulkAlloc, bookingId: 'b2' }])]),
).toHaveLength(1);
// bulk alone, and containers sharing, are fine
expect(validateWagonCargoExclusivity([slot([bulkAlloc])])).toEqual([]);
expect(
validateWagonCargoExclusivity([
slot([containerAlloc, { ...containerAlloc, bookingId: 'c2' }]),
]),
).toEqual([]);
});
}); });
describe('containerWagonsForLines — TEU-aware, ceil booking total once', () => { describe('containerWagonsForLines — TEU-aware, ceil booking total once', () => {
@@ -332,4 +413,91 @@ describe('maxEdgeConsistUsage — the binding edge, not the whole-route sum', ()
loadedWagonCount: 2, loadedWagonCount: 2,
}); });
}); });
it('with a legs map, shared-slot cargo weighs only its own edges (the S-2026-00045 shape)', () => {
// One wagon reused across legs: booking X rides a→b (40T), booking Y
// boards at b with 30T. The slot spans the whole route, but edge a→b
// must weigh 24 + 40 = 64T — not 24 + 70. Tare rides both edges.
const shared = {
tareWeightTons: 24,
assignedWeightTons: 70,
lengthMeters: 14,
boardYardId: null,
alightYardId: null,
allocations: [
{ bookingId: 'X', allocatedWeightTons: 40 },
{ bookingId: 'Y', allocatedWeightTons: 30 },
],
} as never;
const legs = new Map([
['X', { from: 0, to: 1 }],
['Y', { from: 1, to: 2 }],
]);
// Without legs: whole-span scalar on both edges (94T binding edge).
expect(maxEdgeConsistUsage([shared], stops).grossWeightTons).toBe(94);
// With legs: heaviest edge is a→b at 64T (b→c is 54T).
expect(maxEdgeConsistUsage([shared], stops, legs).grossWeightTons).toBe(64);
});
it('falls back to the whole-span scalar when an allocation has no readable weight', () => {
const shared = {
tareWeightTons: 24,
assignedWeightTons: 70,
lengthMeters: 14,
boardYardId: null,
alightYardId: null,
allocations: [{ bookingId: 'X' }],
} as never;
const legs = new Map([['X', { from: 0, to: 1 }]]);
expect(maxEdgeConsistUsage([shared], stops, legs).grossWeightTons).toBe(94);
});
});
describe('validateMixedTrainLimitsPerEdge — leg-aware cargo weighing', () => {
it('does not flag a leg whose overweight is only later-boarding cargo (S-2026-00045)', () => {
// 2 shared wagons, 100T cap. Booking X rides a→b with 30T/wagon, booking Y
// boards at b with 25T/wagon. Whole-span scalars read every edge as
// 2×(20 + 55) = 150T > 100T; the cargo actually aboard is 100T (a→b) and
// 90T (b→c) — both fit.
const slot = (seq: number) => ({
sequenceNo: seq,
wagonTypeId: 'wt-nw5',
wagonTypeCode: 'NW5',
capacityTons: 70,
lengthMeters: 14,
tareWeightTons: 20,
assignedWeightTons: 55,
boardYardId: null,
alightYardId: null,
allocations: [
{
bookingId: 'X',
bookingReference: 'X',
allocatedWeightTons: 30,
loadType: AllocationLoadType.Container,
},
{
bookingId: 'Y',
bookingReference: 'Y',
allocatedWeightTons: 25,
loadType: AllocationLoadType.Container,
},
],
});
const legs = new Map([
['X', { from: 0, to: 1 }],
['Y', { from: 1, to: 2 }],
]);
const run = (withLegs?: typeof legs) =>
validateMixedTrainLimitsPerEdge(
[slot(1), slot(2)] as never,
[{ lengthMeters: 14 }],
{ maxWeightTons: 100 },
['a', 'b', 'c'],
undefined,
withLegs,
);
expect(run()).toHaveLength(2); // both edges falsely overweight without legs
expect(run(legs)).toHaveLength(0);
});
}); });

View File

@@ -200,16 +200,14 @@ export function buildBulkWagonPlan(
); );
const cappedTonSlots = cappedTonSlotsByBooking.reduce((sum, n) => sum + n, 0); const cappedTonSlots = cappedTonSlotsByBooking.reduce((sum, n) => sum + n, 0);
const totalWeight = roundTons( // One bulk booking per wagon — bookings never pool tonnage on a shared
bookings.reduce( // wagon, so each uncapped booking sizes its own wagons (ceil per booking,
(sum, b, i) => // not over the pooled total).
itemSlotsByBooking[i] > 0 || cappedTonSlotsByBooking[i] > 0 const tonSlots = bookings.reduce((sum, b, i) => {
? sum if (itemSlotsByBooking[i] > 0 || cappedTonSlotsByBooking[i] > 0) return sum;
: sum + Number(b.cargoTotalWeightVgm ?? 0), const weight = roundTons(Number(b.cargoTotalWeightVgm ?? 0));
0, return weight > 0 ? sum + Math.ceil(weight / capacity) : sum;
), }, 0);
);
const tonSlots = totalWeight > 0 ? Math.ceil(totalWeight / capacity) : 0;
const slots = Math.max(1, tonSlots + itemSlots + cappedTonSlots); const slots = Math.max(1, tonSlots + itemSlots + cappedTonSlots);
const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({ const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({
@@ -374,13 +372,12 @@ function allocateBookingsToSlots(
if (booking.remainingWeightTons <= 0) { if (booking.remainingWeightTons <= 0) {
bookingIndex += 1; bookingIndex += 1;
} else if (allocatedWeightTons >= takeCap) {
// The cap stopped this wagon short of its rating and the booking has
// more to load. The leftover room is NOT free: `buildBulkWagonPlan`
// already reserved a wagon for the rest, so backfilling another booking
// here would double-book the consist. Close the wagon.
break;
} }
// One bulk booking per wagon: a wagon carrying bulk takes nothing else —
// never a second booking's cargo. `buildBulkWagonPlan` sized the slots
// per booking, so leftover room on this wagon is not free capacity.
// Close the wagon after its single allocation.
break;
} }
return { ...slot, assignedWeightTons, allocations }; return { ...slot, assignedWeightTons, allocations };
@@ -504,6 +501,54 @@ export function sumWagonsRequired(booking: Booking, wagonPlan?: WagonPlanSlot[])
); );
} }
/**
* One wagon carries one kind of cargo AT A TIME: while a bulk load rides, the
* wagon holds nothing else — no container beside it and no second bulk
* booking. Container allocations may share a wagon with each other (TEU rules
* apply).
*
* "At a time" is the whole rule: a wagon whose cargo alights at Dire Dawa is
* empty steel for whatever boards there, so an import container on
* Doraleh→Dire and bulk on Dire→Kality legitimately share one wagon. Pass
* `legs` (booking id → stop-index span) to check per corridor edge; without
* it every allocation is treated as riding the whole route, which is the
* correct reading for a single-leg train.
*/
export function validateWagonCargoExclusivity(
wagonPlan: WagonPlanSlot[],
legs?: Map<string, { from: number; to: number }>,
edgeCount = 1,
): string[] {
const violations: string[] = [];
const edges = Math.max(1, edgeCount);
const spanOf = (bookingId: string) => {
const leg = legs?.get(bookingId);
if (!leg || leg.from < 0 || leg.to > edges || leg.from >= leg.to) {
return { from: 0, to: edges };
}
return leg;
};
for (const slot of wagonPlan) {
if (slot.allocations.length < 2) continue;
// Per edge: who is on this wagon while it rides that edge?
for (let edge = 0; edge < edges; edge += 1) {
const riding = slot.allocations.filter((a) => {
const span = spanOf(a.bookingId);
return span.from <= edge && edge < span.to;
});
if (riding.length < 2) continue;
if (riding.some((a) => a.loadType === AllocationLoadType.Bulk)) {
violations.push(
`Wagon #${slot.sequenceNo} mixes bulk with other cargo — a wagon carrying bulk takes that one load only`,
);
break;
}
}
}
return violations;
}
export function validateBulkWagonSlotWeights(wagonPlan: WagonPlanSlot[]): string[] { export function validateBulkWagonSlotWeights(wagonPlan: WagonPlanSlot[]): string[] {
const violations: string[] = []; const violations: string[] = [];
for (const slot of wagonPlan.filter((s) => s.slotLoadType === 'BULK')) { for (const slot of wagonPlan.filter((s) => s.slotLoadType === 'BULK')) {
@@ -530,6 +575,9 @@ export function validateTrainLimits(
wagonPlan: WagonPlanSlot[], wagonPlan: WagonPlanSlot[],
wagonType: Pick<WagonType, 'lengthMeters'>, wagonType: Pick<WagonType, 'lengthMeters'>,
limits?: TrainLimitConfig, limits?: TrainLimitConfig,
/** Leg-aware cargo exclusivity — see {@link validateWagonCargoExclusivity}. */
legs?: Map<string, { from: number; to: number }>,
edgeCount?: number,
): string[] { ): string[] {
const maxWeightTons = limits?.maxWeightTons ?? MAX_TRAIN_WEIGHT_TONS; const maxWeightTons = limits?.maxWeightTons ?? MAX_TRAIN_WEIGHT_TONS;
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS; const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
@@ -547,6 +595,7 @@ export function validateTrainLimits(
); );
violations.push(...validateBulkWagonSlotWeights(wagonPlan)); violations.push(...validateBulkWagonSlotWeights(wagonPlan));
violations.push(...validateWagonCargoExclusivity(wagonPlan, legs, edgeCount));
return violations; return violations;
} }
@@ -560,6 +609,8 @@ export function validateMixedTrainLimits(
wagonPlan: WagonPlanSlot[], wagonPlan: WagonPlanSlot[],
wagonTypes: Array<Pick<WagonType, 'lengthMeters'>>, wagonTypes: Array<Pick<WagonType, 'lengthMeters'>>,
limits?: TrainLimitConfig, limits?: TrainLimitConfig,
legs?: Map<string, { from: number; to: number }>,
edgeCount?: number,
): string[] { ): string[] {
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS; const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
const minWagonLength = Math.min( const minWagonLength = Math.min(
@@ -573,6 +624,8 @@ export function validateMixedTrainLimits(
wagonPlan, wagonPlan,
{ lengthMeters: minWagonLength }, { lengthMeters: minWagonLength },
{ ...limits, maxWagonsPerTrain }, { ...limits, maxWagonsPerTrain },
legs,
edgeCount,
); );
} }
@@ -590,17 +643,35 @@ export function validateMixedTrainLimitsPerEdge(
stops: string[], stops: string[],
/** Display names parallel to `stops` — violations then name the leg they hit. */ /** Display names parallel to `stops` — violations then name the leg they hit. */
stopLabels?: string[], stopLabels?: string[],
/** Booking id → stop-index span, so cargo exclusivity is judged per edge. */
legs?: Map<string, { from: number; to: number }>,
): string[] { ): string[] {
if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits); const edges = Math.max(1, stops.length - 1);
if (stops.length <= 2) {
return validateMixedTrainLimits(wagonPlan, wagonTypes, limits, legs, edges);
}
const spans = slotSpans(wagonPlan, stops); const spans = slotSpans(wagonPlan, stops);
const label = (i: number) => stopLabels?.[i] ?? stops[i]; const label = (i: number) => stopLabels?.[i] ?? stops[i];
const violations = new Set<string>(); const violations = new Set<string>();
for (let edge = 0; edge < stops.length - 1; edge += 1) { for (let edge = 0; edge < stops.length - 1; edge += 1) {
const active = wagonPlan.filter( // A shared slot rides the UNION of its cargo legs, but only carries each
(_, i) => spans[i].from <= edge && edge < spans[i].to, // booking's cargo on that booking's own edges — weigh the edge with the
); // cargo actually aboard there, not the slot's whole-route scalar, or a
// container boarding at Dire Dawa reads as hauled from Djibouti.
const active = wagonPlan
.filter((_, i) => spans[i].from <= edge && edge < spans[i].to)
.map((slot) => ({
...slot,
assignedWeightTons: slotCargoOnEdge(slot, edge, edges, legs),
}));
if (!active.length) continue; if (!active.length) continue;
for (const violation of validateMixedTrainLimits(active, wagonTypes, limits)) { for (const violation of validateMixedTrainLimits(
active,
wagonTypes,
limits,
legs,
edges,
)) {
violations.add(`Leg ${label(edge)}${label(edge + 1)}: ${violation}`); violations.add(`Leg ${label(edge)}${label(edge + 1)}: ${violation}`);
} }
} }
@@ -620,6 +691,40 @@ export type EdgeUsageSlot = Pick<
allocations?: unknown[]; allocations?: unknown[];
}; };
/**
* Cargo tons a slot actually carries on one edge. With a legs map and readable
* allocation records, each booking's cargo counts only on the edges that
* booking rides (an unmapped booking stays on the slot's whole span). Without
* either — or when any allocation lacks a numeric weight, e.g. persisted rows
* fed through {@link EdgeUsageSlot} — falls back to the slot's whole-span
* `assignedWeightTons`, the pre-existing reading.
*/
function slotCargoOnEdge(
slot: EdgeUsageSlot,
edge: number,
edgeCount: number,
legs?: Map<string, { from: number; to: number }>,
): number {
const wholeSpanCargo = Number(slot.assignedWeightTons ?? 0);
const allocations = (slot.allocations ?? []) as Array<{
bookingId?: string;
allocatedWeightTons?: number | string;
}>;
if (!legs?.size || !allocations.length) return wholeSpanCargo;
let cargo = 0;
for (const allocation of allocations) {
const weight = Number(allocation?.allocatedWeightTons);
if (!Number.isFinite(weight)) return wholeSpanCargo;
const leg = allocation.bookingId ? legs.get(allocation.bookingId) : undefined;
const rides =
!leg || leg.from < 0 || leg.to > edgeCount || leg.from >= leg.to
? true
: leg.from <= edge && edge < leg.to;
if (rides) cargo += weight;
}
return cargo;
}
/** Per-slot stop-index spans; a yard missing from the stop list keeps the slot on the whole route. */ /** Per-slot stop-index spans; a yard missing from the stop list keeps the slot on the whole route. */
function slotSpans( function slotSpans(
wagonPlan: EdgeUsageSlot[], wagonPlan: EdgeUsageSlot[],
@@ -644,8 +749,10 @@ function slotSpans(
export function maxEdgeConsistUsage( export function maxEdgeConsistUsage(
wagonPlan: EdgeUsageSlot[], wagonPlan: EdgeUsageSlot[],
stops: string[], stops: string[],
/** Booking id → stop-index span; cargo then weighs only its own edges. */
legs?: Map<string, { from: number; to: number }>,
): { grossWeightTons: number; lengthMeters: number; loadedWagonCount: number } { ): { grossWeightTons: number; lengthMeters: number; loadedWagonCount: number } {
return perEdgeConsistUsage(wagonPlan, stops).reduce( return perEdgeConsistUsage(wagonPlan, stops, legs).reduce(
(max, e) => ({ (max, e) => ({
grossWeightTons: Math.max(max.grossWeightTons, e.grossWeightTons), grossWeightTons: Math.max(max.grossWeightTons, e.grossWeightTons),
lengthMeters: Math.max(max.lengthMeters, e.lengthMeters), lengthMeters: Math.max(max.lengthMeters, e.lengthMeters),
@@ -673,12 +780,19 @@ export type EdgeConsistUsage = {
export function perEdgeConsistUsage( export function perEdgeConsistUsage(
wagonPlan: EdgeUsageSlot[], wagonPlan: EdgeUsageSlot[],
stops: string[], stops: string[],
/**
* Booking id → stop-index span. When given, a shared slot's cargo weighs
* only the edges its booking rides (tare still rides the slot's whole
* span) — without it a slot's full cargo counts on every edge it spans.
*/
legs?: Map<string, { from: number; to: number }>,
): EdgeConsistUsage[] { ): EdgeConsistUsage[] {
const edgeCount = Math.max(1, stops.length - 1);
const totals = (edge: number, slots: EdgeUsageSlot[]): EdgeConsistUsage => ({ const totals = (edge: number, slots: EdgeUsageSlot[]): EdgeConsistUsage => ({
edge, edge,
grossWeightTons: slots.reduce( grossWeightTons: slots.reduce(
(sum, w) => (sum, w) =>
sum + Number(w.tareWeightTons ?? 0) + Number(w.assignedWeightTons ?? 0), sum + Number(w.tareWeightTons ?? 0) + slotCargoOnEdge(w, edge, edgeCount, legs),
0, 0,
), ),
lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0), lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0),

View File

@@ -490,3 +490,112 @@ describe('planWagonsWithStock — consist split across yards', () => {
expect(result.deferred.map((d) => d.reference)).toEqual(['BKG-G']); expect(result.deferred.map((d) => d.reference)).toEqual(['BKG-G']);
}); });
}); });
describe('planWagonsWithStock — scarcity-aware bulk (one booking per wagon, capped fill)', () => {
// The S-2026-00044 shape: Perishable rides NW5 (30T cap) or PW2 (20T cap);
// containers ride only NW5. NW5 is the shared, scarce type.
const nw5: WagonType = {
id: 'wt-nw5',
code: 'NW5',
name: 'Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
supportsContainer: true,
} as WagonType;
const pw2: WagonType = {
id: 'wt-pw2',
code: 'PW2',
name: 'Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
supportedLoadTypes: ['BULK'],
isActive: true,
supportsContainer: false,
} as WagonType;
const perishable = {
id: 'cargo-perishable',
cargoTypeName: 'Perishable',
wagonTypes: [nw5, pw2],
tonsPerWagonMap: { [nw5.id]: 30, [pw2.id]: 20 },
};
const bulkBooking = (id: string, tons: number): Booking =>
({
id,
reference: id,
freightType: 'BULK',
cargoTotalWeightVgm: tons,
cargoTypeId: perishable.id,
cargoType: perishable,
bookingContainers: [],
}) as unknown as Booking;
const allowed = {
byContainerTypeId: new Map([['ct-1', [nw5]]]),
byCargoTypeId: new Map([[perishable.id, [nw5, pw2]]]),
};
const stockOf = (nw5Count: number, pw2Count: number) => ({
mode: 'YARD' as const,
remainingByTypeId: new Map([
[nw5.id, nw5Count],
[pw2.id, pw2Count],
]),
codesByTypeId: new Map([
[nw5.id, nw5.code],
[pw2.id, pw2.code],
]),
});
it('fills the bulk-only PW2s first when containers compete for NW5', () => {
// 695T Perishable + one 40ft container. Smart split: 10 PW2 × 20T = 200T,
// remainder 495T → 17 NW5 × 30T. The container still gets an NW5.
const container = containerBooking('BKG-C', 1, 1);
container.bookingContainers![0]!.containerType = { code: '40GP', sizeFt: 40 } as never;
const result = planWagonsWithStock({
bookings: [bulkBooking('BKG-BULK', 695), container],
allowed,
stock: stockOf(18, 10),
});
expect(result.deferred).toEqual([]);
const bulkSlots = result.plan.filter((s) => s.slotLoadType === 'BULK');
expect(bulkSlots.filter((s) => s.wagonTypeCode === 'PW2')).toHaveLength(10);
expect(bulkSlots.filter((s) => s.wagonTypeCode === 'NW5')).toHaveLength(17);
// Capped fill: no PW2 slot above 20T, no NW5 bulk slot above 30T.
for (const slot of bulkSlots) {
expect(slot.assignedWeightTons).toBeLessThanOrEqual(
slot.wagonTypeCode === 'PW2' ? 20 : 30,
);
}
const containerSlots = result.plan.filter((s) => s.slotLoadType === 'CONTAINER');
expect(containerSlots).toHaveLength(1);
expect(containerSlots[0]?.wagonTypeCode).toBe('NW5');
});
it('prefers the bigger per-cargo take when nothing competes for the shared type', () => {
// Bulk alone (no containers in the run): NW5 30T beats PW2 20T — fewest
// wagons wins, PW2-first would waste consist length.
const result = planWagonsWithStock({
bookings: [bulkBooking('BKG-BULK', 60)],
allowed,
stock: stockOf(10, 10),
});
expect(result.deferred).toEqual([]);
expect(result.plan).toHaveLength(2);
expect(result.plan.every((s) => s.wagonTypeCode === 'NW5')).toBe(true);
});
it('never puts two bulk bookings on one wagon, even same cargo type', () => {
// 5T + 40T both fit one wagon's cap by tonnage — each still gets its own.
const result = planWagonsWithStock({
bookings: [bulkBooking('BKG-A', 5), bulkBooking('BKG-B', 40)],
allowed,
stock: stockOf(10, 0),
});
expect(result.deferred).toEqual([]);
expect(result.plan).toHaveLength(3); // 5T → 1 wagon; 40T @30 cap → 2 wagons
for (const slot of result.plan) {
expect(new Set(slot.allocations.map((a) => a.bookingId)).size).toBe(1);
}
});
});

View File

@@ -5,6 +5,7 @@ import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { import {
bookingCargoTons, bookingCargoTons,
bulkItemsFitFor, bulkItemsFitFor,
bulkTonsPerWagon,
bulkWagonsForAllowedTypes, bulkWagonsForAllowedTypes,
} from './train-capacity.util'; } from './train-capacity.util';
import { import {
@@ -53,6 +54,13 @@ export type WagonStock = {
* math. * math.
*/ */
byYardId?: Map<string, Map<string, number>>; byYardId?: Map<string, Map<string, number>>;
/**
* Wagons the schedule CUTS mid-route (staff plan): each is stock only up to
* its cut stop. Consumers debit it from its pool on every edge at/after the
* cut, so a leg riding past the cut never counts it. Absent = no cuts.
* `poolYardId` is the wagon's boarding pool ('' on a single-yard consist).
*/
cutWagons?: Array<{ wagonTypeId: string; poolYardId: string; cutYardId: string }>;
}; };
export type FlexPlanResult = { export type FlexPlanResult = {
@@ -153,10 +161,46 @@ const shortageFor = (
), ),
) )
: Math.max(1, containerWagonsForLines(booking.bookingContainers ?? [])); : Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
const wagonsAvailable = candidates.reduce(
(sum, wt) => sum + availableOf(wt.id), const freeByType = candidates.map((wt) => ({ wt, free: availableOf(wt.id) }));
0, 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 { return {
wagonTypeCodes: [...new Set(candidates.map((wt) => wt.code))].join('/'), wagonTypeCodes: [...new Set(candidates.map((wt) => wt.code))].join('/'),
wagonsNeeded, wagonsNeeded,
@@ -187,8 +231,10 @@ const addAllocation = (
* containers/tonnage placed on wagons whose type is allowed for its container * containers/tonnage placed on wagons whose type is allowed for its container
* or cargo type) or is deferred with the shortfall reason. Wagon purity rules: * or cargo type) or is deferred with the shortfall reason. Wagon purity rules:
* a wagon carries one kind at a time — containers pack by TEU (one 40ft, or * a wagon carries one kind at a time — containers pack by TEU (one 40ft, or
* two 20ft, never mixed sizes), bulk fills by weight and never shares a wagon * two 20ft, never mixed sizes); a bulk wagon carries ONE booking's cargo only,
* with a different cargo type. * filled to the cargo type's per-wagon cap. Type choice is scarcity-aware:
* least-shareable wagon type first, so bulk with a PW2 alternative leaves the
* container-capable NW5s to the containers.
*/ */
export function planWagonsWithStock(params: { export function planWagonsWithStock(params: {
bookings: Booking[]; bookings: Booking[];
@@ -221,6 +267,38 @@ export function planWagonsWithStock(params: {
const deferred: DeferredBookingRow[] = []; const deferred: DeferredBookingRow[] = [];
const configIssues = new Set<string>(); const configIssues = new Set<string>();
// Scarcity rank: how many distinct demand groups (container types / bulk
// cargo types) among THESE bookings can ride each wagon type. When a cargo
// can choose, it takes the least-shareable type first, keeping versatile
// types (e.g. container-capable NW5) free for the cargo that has no
// alternative. A type nobody else wants ranks 1; unranked types rank 1 too
// (nothing competes for them).
const demandGroups = new Map<string, WagonType[]>();
for (const b of bookings) {
if (b.freightType === 'CONTAINER') {
for (const line of b.bookingContainers ?? []) {
const containerTypeId = line.containerTypeId ?? line.containerType?.id;
if (!containerTypeId) continue;
demandGroups.set(
`C:${containerTypeId}`,
allowed.byContainerTypeId.get(containerTypeId) ?? [],
);
}
} else {
const cargoTypeId = b.cargoTypeId ?? b.cargoType?.id;
if (cargoTypeId) {
demandGroups.set(`B:${cargoTypeId}`, allowed.byCargoTypeId.get(cargoTypeId) ?? []);
}
}
}
const scarcityRank = new Map<string, number>();
for (const types of demandGroups.values()) {
for (const wt of types) {
scarcityRank.set(wt.id, (scarcityRank.get(wt.id) ?? 0) + 1);
}
}
const rankOf = (wt: WagonType): number => scarcityRank.get(wt.id) ?? 1;
const legFor = (booking: Booking): BookingLeg => { const legFor = (booking: Booking): BookingLeg => {
const leg = legs?.get(booking.id); const leg = legs?.get(booking.id);
if (!leg || leg.from < 0 || leg.to > edgeCount || leg.from >= leg.to) { if (!leg || leg.from < 0 || leg.to > edgeCount || leg.from >= leg.to) {
@@ -253,6 +331,16 @@ export function planWagonsWithStock(params: {
} }
return row; return row;
}; };
// Cut wagons are pre-consumed on every edge at/after their cut stop: they
// are steel for gmp→lebu but not for gmp→dct. Unknown cut yard (no stops
// given / off-corridor) is skipped — conservative, same as before cuts.
for (const cut of stock.cutWagons ?? []) {
const fromEdge = stops.indexOf(cut.cutYardId);
if (fromEdge < 0) continue;
const pool = stock.byYardId ? cut.poolYardId : '';
const row = usedRow(rowKeyFor(cut.wagonTypeId, pool));
for (let e = fromEdge; e < edgeCount; e += 1) row[e] += 1;
}
const availableFor = (wagonTypeId: string, leg: BookingLeg): number => { const availableFor = (wagonTypeId: string, leg: BookingLeg): number => {
const pool = poolOf(leg); const pool = poolOf(leg);
const total = totalFor(wagonTypeId, pool); const total = totalFor(wagonTypeId, pool);
@@ -277,18 +365,26 @@ export function planWagonsWithStock(params: {
kind: SlotLoadType, kind: SlotLoadType,
cargoTypeId: string | null, cargoTypeId: string | null,
leg: BookingLeg, leg: BookingLeg,
/** Bulk only: the booking's cargo type, for its per-wagon tonnage cap. */
cargoType?: Booking['cargoType'],
): OpenSlot | PlacementProblem => { ): OpenSlot | PlacementProblem => {
const inStock = candidates.filter((wt) => availableFor(wt.id, leg) > 0); const inStock = candidates.filter((wt) => availableFor(wt.id, leg) > 0);
if (!inStock.length) { if (!inStock.length) {
return { kind: 'stock', message: noStockMessage(candidates, leg), candidates }; return { kind: 'stock', message: noStockMessage(candidates, leg), candidates };
} }
// Bulk favors the largest wagon (fewest wagons for the tonnage); containers // Least-shareable type first (see scarcityRank) so cargo with alternatives
// never starves cargo without one. Bulk then favors the biggest per-wagon
// take for THIS cargo (its configured cap, not the raw rating); containers
// favor the deepest stock so the consist drains evenly. Ties keep config order. // favor the deepest stock so the consist drains evenly. Ties keep config order.
const bulkTakeOf = (wt: WagonType): number =>
bulkTonsPerWagon(cargoType, wt.id, Number(wt.capacityTons));
const chosen = [...inStock].sort((a, b) => const chosen = [...inStock].sort((a, b) =>
kind === 'BULK' kind === 'BULK'
? Number(b.capacityTons) - Number(a.capacityTons) || ? rankOf(a) - rankOf(b) ||
bulkTakeOf(b) - bulkTakeOf(a) ||
availableFor(b.id, leg) - availableFor(a.id, leg) availableFor(b.id, leg) - availableFor(a.id, leg)
: availableFor(b.id, leg) - availableFor(a.id, leg), : rankOf(a) - rankOf(b) ||
availableFor(b.id, leg) - availableFor(a.id, leg),
)[0]; )[0];
const pool = poolOf(leg); const pool = poolOf(leg);
const row = usedRow(rowKeyFor(chosen.id, pool)); const row = usedRow(rowKeyFor(chosen.id, pool));
@@ -298,7 +394,10 @@ export function planWagonsWithStock(params: {
teuPerEdge: new Array<number>(edgeCount).fill(0), teuPerEdge: new Array<number>(edgeCount).fill(0),
kind, kind,
cargoTypeId, cargoTypeId,
freeCapacityTons: Number(chosen.capacityTons), // A bulk wagon fills to the cargo type's configured per-wagon cap
// (Perishable: 20T on PW2, 30T on NW5), never the raw 70T rating.
freeCapacityTons:
kind === 'BULK' ? bulkTakeOf(chosen) : Number(chosen.capacityTons),
legKey: legKeyOf(leg), legKey: legKeyOf(leg),
covered: { ...leg }, covered: { ...leg },
pool, pool,
@@ -370,8 +469,14 @@ export function planWagonsWithStock(params: {
} }
const allowedIds = new Set(candidates.map((wt) => wt.id)); const allowedIds = new Set(candidates.map((wt) => wt.id));
const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20); const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20);
// A BULK wagon whose cargo alights before this unit boards is empty
// steel again and may carry containers on the later leg (and vice
// versa — see the bulk reuse pass). While both ride together, the
// kinds never mix.
const disjointFrom = (open: OpenSlot): boolean =>
open.covered.to <= leg.from || leg.to <= open.covered.from;
const fitsSlot = (open: OpenSlot): boolean => const fitsSlot = (open: OpenSlot): boolean =>
open.kind === 'CONTAINER' && (open.kind === 'CONTAINER' || disjointFrom(open)) &&
allowedIds.has(open.slot.wagonTypeId) && allowedIds.has(open.slot.wagonTypeId) &&
teuFits(open, leg, teu) && teuFits(open, leg, teu) &&
canExtendSpan(open, leg); canExtendSpan(open, leg);
@@ -423,53 +528,80 @@ export function planWagonsWithStock(params: {
const perItemTons = perItem ? remainingWeight / quantity : 0; const perItemTons = perItem ? remainingWeight / quantity : 0;
let remainingItems = perItem ? quantity : 0; let remainingItems = perItem ? quantity : 0;
/** Whole items one wagon of this slot's type can still take. */ /** Fresh wagon's whole-item budget: items-fit map floor'd by (capped) tonnage. */
const itemRoomOf = (open: OpenSlot): number =>
Math.min(
open.freeItems ?? Number.MAX_SAFE_INTEGER,
perItemTons > 0 ? Math.floor(open.freeCapacityTons / perItemTons) : 0,
);
/** Fresh wagon's whole-item budget: items-fit map floor'd by tonnage. */
const itemBudgetOf = (open: OpenSlot): number => { const itemBudgetOf = (open: OpenSlot): number => {
const fit = bulkItemsFitFor(booking.cargoType, open.slot.wagonTypeId); const fit = bulkItemsFitFor(booking.cargoType, open.slot.wagonTypeId);
const byTonnage = const byTonnage =
perItemTons > 0 perItemTons > 0
? Math.max(1, Math.floor(Number(open.slot.capacityTons) / perItemTons)) ? Math.max(1, Math.floor(open.freeCapacityTons / perItemTons))
: 1; : 1;
return Math.min(fit ?? Number.MAX_SAFE_INTEGER, byTonnage); return Math.min(fit ?? Number.MAX_SAFE_INTEGER, byTonnage);
}; };
let placedAnywhere = false; let placedAnywhere = false;
// Per-item: prefer the type carrying the most whole items per wagon. // Per-item: least-shareable type first (same scarcity rule as openSlot),
// openSlot's own capacity sort is stable, so this order breaks its ties. // then the type carrying the most whole items per wagon.
const itemBudgetOfType = (wt: WagonType): number => const itemBudgetOfType = (wt: WagonType): number =>
Math.min( Math.min(
bulkItemsFitFor(booking.cargoType, wt.id) ?? Number.MAX_SAFE_INTEGER, bulkItemsFitFor(booking.cargoType, wt.id) ?? Number.MAX_SAFE_INTEGER,
perItemTons > 0 perItemTons > 0
? Math.max(1, Math.floor(Number(wt.capacityTons) / perItemTons)) ? Math.max(
1,
Math.floor(
bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons)) /
perItemTons,
),
)
: 1, : 1,
); );
const orderedCandidates = perItem const orderedCandidates = perItem
? [...candidates].sort((a, b) => itemBudgetOfType(b) - itemBudgetOfType(a)) ? [...candidates].sort(
(a, b) => rankOf(a) - rankOf(b) || itemBudgetOfType(b) - itemBudgetOfType(a),
)
: candidates; : candidates;
// Top off wagons already carrying THIS cargo type before opening new ones. // One bulk booking per wagon PER LEG: a wagon carrying bulk takes that one
// ponytail: per-item cargo only shares wagons that were opened per-item // booking's cargo for as long as it rides — never topped up from another
// (freeItems tracked); mixing itemized and loose loads of one cargo type // booking on the same edges, even of the same cargo type.
// on one wagon is not modeled — open a new wagon instead. //
for (const open of openSlots) { // A wagon whose cargo ALIGHTS before this booking boards is free steel
// again, though: an import container uncoupled at Dire Dawa leaves its
// wagon empty for bulk loading there. Reuse those disjoint-leg slots
// before opening new stock — containers already do this, and without it a
// train with 3 wagons could not seat 3 wagons of leg-1 cargo plus 3 of
// leg-2 cargo.
const disjoint = (open: OpenSlot): boolean =>
open.covered.to <= leg.from || leg.to <= open.covered.from;
const reusable = openSlots.filter(
(open) =>
disjoint(open) &&
allowedIds.has(open.slot.wagonTypeId) &&
// A pooled wagon boards at its own yard; it cannot ride backwards.
!(open.pool && leg.from < open.covered.from),
);
for (const open of reusable) {
if (perItem ? remainingItems <= 0 : remainingWeight <= 0) break; if (perItem ? remainingItems <= 0 : remainingWeight <= 0) break;
if (open.kind !== 'BULK') continue; const wagonType = candidates.find((wt) => wt.id === open.slot.wagonTypeId);
if (open.legKey !== legKey) continue; if (!wagonType) continue;
if (open.cargoTypeId !== cargoTypeId) continue; const room = bulkTonsPerWagon(
if (!allowedIds.has(open.slot.wagonTypeId)) continue; booking.cargoType,
if (open.freeCapacityTons <= 0) continue; open.slot.wagonTypeId,
if (perItem !== (open.freeItems !== undefined)) continue; Number(open.slot.capacityTons),
const takeItems = perItem ? Math.min(itemRoomOf(open), remainingItems) : 0; );
if (perItem && takeItems <= 0) continue; if (!(room > 0)) continue;
const take = perItem let take: number;
? roundTons(takeItems * perItemTons) if (perItem) {
: roundTons(Math.min(open.freeCapacityTons, remainingWeight)); const budget = Math.min(
bulkItemsFitFor(booking.cargoType, open.slot.wagonTypeId) ??
Number.MAX_SAFE_INTEGER,
perItemTons > 0 ? Math.max(1, Math.floor(room / perItemTons)) : 1,
);
const takeItems = Math.max(1, Math.min(budget, remainingItems));
take = roundTons(Math.min(takeItems * perItemTons, remainingWeight));
remainingItems -= takeItems;
} else {
take = roundTons(Math.min(room, remainingWeight));
}
addAllocation( addAllocation(
open.slot, open.slot,
booking.id, booking.id,
@@ -477,11 +609,9 @@ export function planWagonsWithStock(params: {
take, take,
AllocationLoadType.Bulk, AllocationLoadType.Bulk,
); );
open.freeCapacityTons = roundTons(open.freeCapacityTons - take); // The wagon now rides this leg too — it is the same physical steel, so
if (perItem) { // no extra stock is consumed beyond extending its span.
open.freeItems = (open.freeItems ?? 0) - takeItems; extendSpan(open, leg);
remainingItems -= takeItems;
}
remainingWeight = roundTons(remainingWeight - take); remainingWeight = roundTons(remainingWeight - take);
placedAnywhere = true; placedAnywhere = true;
} }
@@ -498,6 +628,7 @@ export function planWagonsWithStock(params: {
'BULK', 'BULK',
cargoTypeId, cargoTypeId,
leg, leg,
booking.cargoType,
); );
if ('message' in openedSlot) return openedSlot; if ('message' in openedSlot) return openedSlot;
let take: number; let take: number;

View File

@@ -159,3 +159,52 @@ describe('WagonStockLedger — multi-yard consist', () => {
expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(53); expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(53);
}); });
}); });
describe('WagonStockLedger — cut wagons (S-2026-00050 shape)', () => {
// gmp -> lebu -> mojo -> adama -> dct. 3 NW5 + 2 PW2: two NW5 board at gmp
// (one cut at lebu), one NW5 boards at mojo; both PW2 board at gmp.
const stops = ['gmp', 'lebu', 'mojo', 'adama', 'dct'];
const makeLedger = () => {
const ledger = new WagonStockLedger(
new Map([
['nw5', 3],
['pw2', 2],
]),
stops.length - 1,
new Map([
['gmp', new Map([['nw5', 2], ['pw2', 2]])],
['mojo', new Map([['nw5', 1]])],
]),
stops,
);
ledger.debitCutWagons([{ wagonTypeId: 'nw5', poolYardId: 'gmp', cutYardId: 'lebu' }]);
return ledger;
};
const leg = (from: number, to: number) => ({ fromEdge: from, toEdge: to });
it('a leg past the cut sees only the wagons that reach it', () => {
const ledger = makeLedger();
// gmp -> dct: 2 NW5 stand at gmp but one is cut at lebu — only 1 rides through.
expect(ledger.availableFor(['nw5'], leg(0, 4))).toBe(1);
// gmp -> lebu: both gmp NW5 serve the short leg.
expect(ledger.availableFor(['nw5'], leg(0, 1))).toBe(2);
// PW2 uncut — both ride anywhere from gmp.
expect(ledger.availableFor(['pw2'], leg(0, 4))).toBe(2);
// mojo -> dct: the mojo pool's own NW5, untouched by the gmp cut.
expect(ledger.availableFor(['nw5'], leg(2, 4))).toBe(1);
});
it('cut debit and booking consumption stack', () => {
const ledger = makeLedger();
expect(ledger.consume(['nw5'], 1, leg(0, 4))).toBe(1);
expect(ledger.availableFor(['nw5'], leg(0, 4))).toBe(0);
// Short leg still has the cut wagon (1 = 2 total 1 consumed through-rider).
expect(ledger.availableFor(['nw5'], leg(0, 1))).toBe(1);
});
it('ignores a cut yard that is not on the stops', () => {
const ledger = makeLedger();
ledger.debitCutWagons([{ wagonTypeId: 'pw2', poolYardId: 'gmp', cutYardId: 'elsewhere' }]);
expect(ledger.availableFor(['pw2'], leg(0, 4))).toBe(2);
});
});

View File

@@ -75,6 +75,32 @@ export class WagonStockLedger {
return Math.max(0, total - busiest); return Math.max(0, total - busiest);
} }
/**
* Pre-debit wagons the schedule CUTS mid-route: each cut wagon occupies its
* pool's stock on every edge at/after its cut stop, so a leg riding past the
* cut never counts it ("2 NW5 free from gmp" reads 1 when one cuts at Lebu).
* A cut yard not on this ledger's stops is skipped — conservative, matches
* the pre-cut behavior.
*/
debitCutWagons(
cuts: ReadonlyArray<{ wagonTypeId: string; poolYardId: string; cutYardId: string }>,
): void {
for (const cut of cuts) {
const fromEdge = this.stops.indexOf(cut.cutYardId);
if (fromEdge < 0) continue;
const pool = this.byYardId ? cut.poolYardId : '';
const key = pool ? `${pool}\u0000${cut.wagonTypeId}` : cut.wagonTypeId;
let row = this.usedPerEdge.get(key);
if (!row) {
row = new Array<number>(this.edgeCount).fill(0);
this.usedPerEdge.set(key, row);
}
for (let edge = fromEdge; edge < this.edgeCount; edge += 1) {
row[edge] = (row[edge] ?? 0) + 1;
}
}
}
/** /**
* Free wagons across every type a booking may ride. A cargo/container type * Free wagons across every type a booking may ride. A cargo/container type
* mapped to several wagon types can use any of them, so they add up. * mapped to several wagon types can use any of them, so they add up.

View File

@@ -16,6 +16,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common'; import { CurrentUser } from '@edr/api-common';
import { FleetManage, FleetView } from '../../common/booking-guards'; import { FleetManage, FleetView } from '../../common/booking-guards';
import { PaginationQueryDto } from '../../common/dto/pagination-query.dto';
import type { AuthUserPayload } from '../../common/resolve-auth-user-id'; import type { AuthUserPayload } from '../../common/resolve-auth-user-id';
import { resolveAuthUserId } from '../../common/resolve-auth-user-id'; import { resolveAuthUserId } from '../../common/resolve-auth-user-id';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
@@ -78,6 +79,24 @@ export class TrainBuilderController {
return this.trainBuilderService.getComposition(id); return this.trainBuilderService.getComposition(id);
} }
@Get(':id/history')
@ApiOperation({
summary:
"Wagon adjustment history of this built train: who attached/detached/switched which wagon, when and where — builder edits and trip events alike",
})
history(@Param('id', ParseUUIDPipe) id: string, @Query() query: PaginationQueryDto) {
return this.trainBuilderService.getTrainHistory(id, query);
}
@Get(':id/detached-wagons')
@ApiOperation({
summary:
'Wagons previously detached from this train that are still loose — with when/where/by whom they were last detached, ready to re-attach',
})
detachedWagons(@Param('id', ParseUUIDPipe) id: string, @Query() query: PaginationQueryDto) {
return this.trainBuilderService.getDetachedWagons(id, query);
}
@Put(':id/locomotives') @Put(':id/locomotives')
@FleetManage(FREIGHT_PERMS.trains.changeLocomotives) @FleetManage(FREIGHT_PERMS.trains.changeLocomotives)
@ApiOperation({ summary: 'Replace the locomotive set (minimum 1, same yard)' }) @ApiOperation({ summary: 'Replace the locomotive set (minimum 1, same yard)' })

View File

@@ -231,6 +231,117 @@ export class TrainBuilderService {
return new Map(rows.map(({ trainId, ...schedule }) => [trainId, schedule])); return new Map(rows.map(({ trainId, ...schedule }) => [trainId, schedule]));
} }
/**
* Wagon adjustment history of one built train, newest first: builder
* attaches/detaches (no schedule) and trip events (real cuts, couples,
* consist adjustments — carrying their schedule reference) alike.
*/
async getTrainHistory(trainId: string, query: { page?: number; pageSize?: number } = {}) {
const { page, pageSize, skip, take } = normalizePagination(query);
const [countRows, rows]: [
Array<{ total: string }>,
Array<{
id: string;
action: string;
subject: string;
yardLabel: string | null;
actor: string | null;
scheduleReference: string | null;
occurredAt: Date;
}>,
] = await Promise.all([
this.dataSource.query(
`SELECT count(*) AS total
FROM freight.schedule_wagon_adjustment_logs l
WHERE l.train_id = $1
AND l.deleted_at IS NULL`,
[trainId],
),
this.dataSource.query(
`SELECT l.id,
l.action,
l.wagon_number AS "subject",
COALESCE(y.label, y.code) AS "yardLabel",
COALESCE(u.username, u.email) AS "actor",
ts.reference AS "scheduleReference",
l.occurred_at AS "occurredAt"
FROM freight.schedule_wagon_adjustment_logs l
LEFT JOIN freight.yards y ON y.id = l.yard_id
LEFT JOIN iam.users u ON u.id = l.adjusted_by_user_id
LEFT JOIN freight.train_schedules ts ON ts.id = l.train_schedule_id
WHERE l.train_id = $1
AND l.deleted_at IS NULL
ORDER BY l.occurred_at DESC
LIMIT $2 OFFSET $3`,
[trainId, take, skip],
),
]);
const total = Number(countRows[0]?.total ?? 0);
return { items: rows, meta: buildPaginationMeta(total, page, pageSize) };
}
/**
* Wagons last detached from THIS train that are still loose (no train,
* AVAILABLE) — the re-attach shortlist, with when/where/by whom each was
* last detached. Derived from the adjustment log, no denormalized column.
*/
async getDetachedWagons(trainId: string, query: { page?: number; pageSize?: number } = {}) {
const { page, pageSize, skip, take } = normalizePagination(query);
const lastRemovalSql = `
SELECT DISTINCT ON (l.wagon_id)
l.wagon_id AS "wagonId",
l.occurred_at AS "detachedAt",
COALESCE(y.label, y.code) AS "detachedYardLabel",
COALESCE(u.username, u.email) AS "detachedBy"
FROM freight.schedule_wagon_adjustment_logs l
LEFT JOIN freight.yards y ON y.id = l.yard_id
LEFT JOIN iam.users u ON u.id = l.adjusted_by_user_id
WHERE l.train_id = $1
AND l.action = 'REMOVE'
AND l.deleted_at IS NULL
ORDER BY l.wagon_id, l.occurred_at DESC`;
const stillLoose = `w.deleted_at IS NULL AND w.train_id IS NULL AND w.status = 'AVAILABLE'`;
const [countRows, rows]: [
Array<{ total: string }>,
Array<{
wagonId: string;
wagonNumber: string;
wagonTypeCode: string | null;
currentYardLabel: string | null;
detachedAt: Date;
detachedYardLabel: string | null;
detachedBy: string | null;
}>,
] = await Promise.all([
this.dataSource.query(
`SELECT count(*) AS total
FROM (${lastRemovalSql}) last_removal
JOIN freight.wagons w ON w.id = last_removal."wagonId"
WHERE ${stillLoose}`,
[trainId],
),
this.dataSource.query(
`SELECT last_removal."wagonId",
w.wagon_number AS "wagonNumber",
wt.code AS "wagonTypeCode",
COALESCE(cy.label, cy.code) AS "currentYardLabel",
last_removal."detachedAt",
last_removal."detachedYardLabel",
last_removal."detachedBy"
FROM (${lastRemovalSql}) last_removal
JOIN freight.wagons w ON w.id = last_removal."wagonId"
LEFT JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
LEFT JOIN freight.yards cy ON cy.id = w.current_yard_id
WHERE ${stillLoose}
ORDER BY last_removal."detachedAt" DESC
LIMIT $2 OFFSET $3`,
[trainId, take, skip],
),
]);
const total = Number(countRows[0]?.total ?? 0);
return { items: rows, meta: buildPaginationMeta(total, page, pageSize) };
}
/** Full consist: yard, ordered locomotives + wagons, totals vs. haul limits. */ /** Full consist: yard, ordered locomotives + wagons, totals vs. haul limits. */
async getComposition(id: string) { async getComposition(id: string) {
const train = await this.dataSource.getRepository(Train).findOne({ const train = await this.dataSource.getRepository(Train).findOne({
@@ -721,6 +832,7 @@ export class TrainBuilderService {
toYardId: yardId, toYardId: yardId,
kind: WagonMovementKind.Maintenance, kind: WagonMovementKind.Maintenance,
note: notes.movementNote, note: notes.movementNote,
movedByUserId: userId,
occurredAt: new Date(), occurredAt: new Date(),
}), }),
); );
@@ -1097,15 +1209,14 @@ export class TrainBuilderService {
.getRepository(TrainSet) .getRepository(TrainSet)
.update(trainSet.id, { wagonCount, totalWeightTons, totalLengthMeters }); .update(trainSet.id, { wagonCount, totalWeightTons, totalLengthMeters });
} }
if (!schedule) return null; // Log the consist change even when the train has no live schedule — the
// builder's own detach/attach is the train's history too (who removed
await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount }); // which wagon, when, where), and the detached-wagons tab reads it back.
const now = new Date(); const now = new Date();
await manager.getRepository(ScheduleWagonAdjustmentLog).save( await manager.getRepository(ScheduleWagonAdjustmentLog).save(
changes.map((c) => changes.map((c) =>
manager.getRepository(ScheduleWagonAdjustmentLog).create({ manager.getRepository(ScheduleWagonAdjustmentLog).create({
trainScheduleId: schedule.id, trainScheduleId: schedule?.id ?? null,
trainId, trainId,
action: c.action, action: c.action,
wagonId: c.wagonId, wagonId: c.wagonId,
@@ -1117,6 +1228,10 @@ export class TrainBuilderService {
), ),
); );
if (!schedule) return null;
await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount });
// The FULL/reopen decision must run AFTER the transaction commits — see // The FULL/reopen decision must run AFTER the transaction commits — see
// reconcileWindowAfterConsistChange. // reconcileWindowAfterConsistChange.
return { scheduleId: schedule.id, wasFull: schedule.bookingWindowStatus === 'FULL' }; return { scheduleId: schedule.id, wasFull: schedule.bookingWindowStatus === 'FULL' };

View File

@@ -91,4 +91,18 @@ export class ListWagonsQueryDto {
@IsOptional() @IsOptional()
@IsDateString() @IsDateString()
createdTo?: string; createdTo?: string;
@ApiPropertyOptional({
description: 'Last maintenance flip on or after this day (YYYY-MM-DD)',
})
@IsOptional()
@IsDateString()
maintenanceFrom?: string;
@ApiPropertyOptional({
description: 'Last maintenance flip on or before this day (YYYY-MM-DD)',
})
@IsOptional()
@IsDateString()
maintenanceTo?: string;
} }

View File

@@ -90,6 +90,27 @@ export class WagonsService {
}); });
} }
// Last-maintenance range, both ends inclusive. There's no column to
// compare directly — "last maintenance" is the latest status-log flip to
// MAINTENANCE (see attachStatusDates below), so this mirrors that same
// MAX(...) FILTER(...) as a correlated subquery against the same table.
if (query.maintenanceFrom) {
qb.andWhere(
`(SELECT MAX(l.created_at) FROM freight.wagon_status_logs l
WHERE l.wagon_id = w.id AND l.to_status = '${WagonStatus.Maintenance}')
>= CAST(:maintenanceFrom AS date)`,
{ maintenanceFrom: query.maintenanceFrom },
);
}
if (query.maintenanceTo) {
qb.andWhere(
`(SELECT MAX(l.created_at) FROM freight.wagon_status_logs l
WHERE l.wagon_id = w.id AND l.to_status = '${WagonStatus.Maintenance}')
< CAST(:maintenanceTo AS date) + INTERVAL '1 day'`,
{ maintenanceTo: query.maintenanceTo },
);
}
// Search matches the wagon number or either run number. // Search matches the wagon number or either run number.
if (search) { if (search) {
qb.andWhere( qb.andWhere(

View File

@@ -2,10 +2,15 @@ import {
BOOKING_RULE_ENGINE_PERMISSIONS, BOOKING_RULE_ENGINE_PERMISSIONS,
BOOKING_RULE_ENGINE_PERMISSION_KEYS, BOOKING_RULE_ENGINE_PERMISSION_KEYS,
deriveReadPermissions, deriveReadPermissions,
FREIGHT_PERMS,
POSITION_PERMISSION_PRESETS, POSITION_PERMISSION_PRESETS,
ROLE_PERMISSION_PRESETS, ROLE_PERMISSION_PRESETS,
} from './freight-permissions.registry'; } from './freight-permissions.registry';
/** Shorthand for the one overview-layout permission a role/position preset gets. */
const overviewLayout = (key: Parameters<typeof FREIGHT_PERMS.overview.layout>[0]): string =>
FREIGHT_PERMS.overview.layout(key);
export type FreightSeedRole = { export type FreightSeedRole = {
key: string; key: string;
name: { en: string }; name: { en: string };
@@ -248,48 +253,55 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [
{ {
key: "edr_line_staff", key: "edr_line_staff",
name: { en: "EDR Line Staff" }, name: { en: "EDR Line Staff" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.lineStaff], // OCC: the legacy role form of the control-centre desk (no position preset
// grants this layout — see EDR_FREIGHT_POSITIONS).
permissionKeys: [...ROLE_PERMISSION_PRESETS.lineStaff, overviewLayout("occ")],
}, },
{ {
key: "edr_operations_officer", key: "edr_operations_officer",
name: { en: "EDR Operations Officer" }, name: { en: "EDR Operations Officer" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.operationsOfficer], permissionKeys: [
...ROLE_PERMISSION_PRESETS.operationsOfficer,
overviewLayout("operation"),
],
}, },
{ {
key: "edr_director", key: "edr_director",
name: { en: "EDR Director" }, name: { en: "EDR Director" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.director], permissionKeys: [...ROLE_PERMISSION_PRESETS.director, overviewLayout("executive")],
}, },
{ {
key: "edr_ceo", key: "edr_ceo",
name: { en: "EDR CEO" }, name: { en: "EDR CEO" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.ceo], permissionKeys: [...ROLE_PERMISSION_PRESETS.ceo, overviewLayout("executive")],
}, },
{ {
key: "edr_finance", key: "edr_finance",
name: { en: "EDR Finance" }, name: { en: "EDR Finance" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.finance], // No position preset grants this layout — Finance only exists as a Role.
permissionKeys: [...ROLE_PERMISSION_PRESETS.finance, overviewLayout("finance")],
}, },
{ {
key: "edr_marketing", key: "edr_marketing",
name: { en: "EDR Marketing" }, name: { en: "EDR Marketing" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.marketing], permissionKeys: [...ROLE_PERMISSION_PRESETS.marketing, overviewLayout("marketer")],
}, },
{ {
key: "edr_gl_ethiopia", key: "edr_gl_ethiopia",
name: { en: "EDR Global Logistics — Ethiopia" }, name: { en: "EDR Global Logistics — Ethiopia" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.glEthiopia], permissionKeys: [...ROLE_PERMISSION_PRESETS.glEthiopia, overviewLayout("clearance")],
}, },
{ {
key: "edr_gl_djibouti", key: "edr_gl_djibouti",
name: { en: "EDR Global Logistics — Djibouti" }, name: { en: "EDR Global Logistics — Djibouti" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.glDjibouti], permissionKeys: [...ROLE_PERMISSION_PRESETS.glDjibouti, overviewLayout("clearance")],
}, },
{ {
key: "edr_org_manager", key: "edr_org_manager",
name: { en: "EDR Org Manager" }, name: { en: "EDR Org Manager" },
permissionKeys: [ permissionKeys: [
...BOOKING_RULE_ENGINE_PERMISSION_KEYS, ...BOOKING_RULE_ENGINE_PERMISSION_KEYS,
overviewLayout("executive"),
...EMPLOYEE_REGISTRATION_PERMISSIONS.map((p) => p.key), ...EMPLOYEE_REGISTRATION_PERMISSIONS.map((p) => p.key),
...ROLE_ASSIGNMENT_PERMISSIONS.map((p) => p.key), ...ROLE_ASSIGNMENT_PERMISSIONS.map((p) => p.key),
...HIERARCHY_UNIT_PERMISSIONS.map((p) => p.key), ...HIERARCHY_UNIT_PERMISSIONS.map((p) => p.key),
@@ -326,15 +338,17 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [
* PositionPermission rows (NOT Role/RolePermission). Users get their access by * PositionPermission rows (NOT Role/RolePermission). Users get their access by
* being assigned to a Position via EmployeePosition. * being assigned to a Position via EmployeePosition.
*/ */
// No position preset grants the "occ" or "finance" overview layouts today —
// see the comments on edr_line_staff / edr_finance above.
export const EDR_FREIGHT_POSITIONS: FreightSeedPosition[] = [ export const EDR_FREIGHT_POSITIONS: FreightSeedPosition[] = [
{ key: "chief", name: { en: "Chief" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.chief] }, { key: "chief", name: { en: "Chief" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.chief, overviewLayout("executive")] },
{ key: "director", name: { en: "Director" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.director] }, { key: "director", name: { en: "Director" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.director, overviewLayout("executive")] },
{ key: "ceo", name: { en: "CEO" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.ceo] }, { key: "ceo", name: { en: "CEO" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.ceo, overviewLayout("executive")] },
{ key: "ethiopian_gl", name: { en: "Ethiopian GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.ethiopianGl] }, { key: "ethiopian_gl", name: { en: "Ethiopian GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.ethiopianGl, overviewLayout("clearance")] },
{ key: "djibouti_gl", name: { en: "Djibouti GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.djiboutiGl] }, { key: "djibouti_gl", name: { en: "Djibouti GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.djiboutiGl, overviewLayout("clearance")] },
{ key: "marketer", name: { en: "Marketer" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.marketer] }, { key: "marketer", name: { en: "Marketer" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.marketer, overviewLayout("marketer")] },
{ key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation] }, { key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation, overviewLayout("operation")] },
{ key: "operations_chief", name: { en: "Operations Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.operationsChief] }, { key: "operations_chief", name: { en: "Operations Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.operationsChief, overviewLayout("operation")] },
{ key: "dispatcher", name: { en: "Dispatcher" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.dispatcher] }, { key: "dispatcher", name: { en: "Dispatcher" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.dispatcher, overviewLayout("operation")] },
{ key: "truck_machinery_chief", name: { en: "Truck & Machinery Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.truckMachineryChief] }, { key: "truck_machinery_chief", name: { en: "Truck & Machinery Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.truckMachineryChief, overviewLayout("operation")] },
]; ];

View File

@@ -49,13 +49,14 @@ const perm = (id: string, key: string, en: string): FreightPermissionSeed => ({
}); });
/** /**
* One entry per report definition (see modules/reports/definitions). Each * Every report key ever seeded, in seed order.
* gets its own permission, gated behind the `reports:view` master key that *
* opens the Reports section itself. * NEVER reorder or delete an entry: reportPermId derives a permission's uuid
* Keep new keys at the END: reportPermId derives ids from list index, so a * from its index here, so a shift would re-map ids already granted to roles.
* mid-list insert would shift ids already seeded for later keys. * Retiring a report means adding it to RETIRED_REPORT_KEYS, not removing it.
* New keys go at the END.
*/ */
export const REPORT_KEYS = [ const SEEDED_REPORT_KEYS = [
"bookings-list", "bookings-list",
"revenue-by-customer", "revenue-by-customer",
"aging-receivables", "aging-receivables",
@@ -98,21 +99,100 @@ export const REPORT_KEYS = [
"cargo-volume-by-station", "cargo-volume-by-station",
] as const; ] as const;
export type ReportKey = (typeof REPORT_KEYS)[number]; /**
* Reports whose definition was deleted (see modules/reports/definitions) — a
* flat list the Exports module and its backoffice table already serve, or a
* narrower view of a report that supersedes it. Their permissions stay seeded
* so no live report's uuid moves; nothing resolves them to a definition.
*/
const RETIRED_REPORT_KEYS = [
"bookings-list",
"customer-status",
"contract-lifecycle",
"invoices-by-status",
"payments-by-status",
"revenue-summary",
] as const;
export const reportPermissionKey = (key: ReportKey): string => export type ReportKey = Exclude<
(typeof SEEDED_REPORT_KEYS)[number],
(typeof RETIRED_REPORT_KEYS)[number]
>;
/** One entry per live report definition — what the catalog and presets use. */
export const REPORT_KEYS: readonly ReportKey[] = SEEDED_REPORT_KEYS.filter(
(k): k is ReportKey =>
!(RETIRED_REPORT_KEYS as readonly string[]).includes(k),
);
export const reportPermissionKey = (key: string): string =>
`edr_freight_app:reports:${key.replace(/-/g, "_")}:view`; `edr_freight_app:reports:${key.replace(/-/g, "_")}:view`;
const reportPermId = (index: number): string => const reportPermId = (index: number): string =>
`a4f00002-0001-4000-8000-${(index + 1).toString(16).padStart(12, "0")}`; `a4f00002-0001-4000-8000-${(index + 1).toString(16).padStart(12, "0")}`;
const titleCase = (slug: string): string => const titleCase = (slug: string): string =>
slug.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join(" "); slug
.split("-")
.map((w) => w[0].toUpperCase() + w.slice(1))
.join(" ");
export const REPORT_PERMISSIONS: FreightPermissionSeed[] = REPORT_KEYS.map( // Seeded from SEEDED_REPORT_KEYS, not REPORT_KEYS: a retired report keeps its
(key, index) => // index and its permission row, which is what stops the live ids from moving.
perm(reportPermId(index), reportPermissionKey(key), `Report: ${titleCase(key)}`), export const REPORT_PERMISSIONS: FreightPermissionSeed[] =
); SEEDED_REPORT_KEYS.map((key, index) =>
perm(
reportPermId(index),
reportPermissionKey(key),
`Report: ${titleCase(key)}`,
),
);
/**
* Overview dashboard layouts (see the backoffice's role-dashboards.config.ts,
* where `LAYOUTS` renders one composition per key). Unlike reports, a caller
* lands on exactly ONE layout, so `OVERVIEW_LAYOUT_KEYS` is also the priority
* order: whoever resolves the permission set picks the FIRST key here the
* caller holds — the specific operational view wins over the broad executive
* one, same rule the old role/position-key table encoded.
*
* NEVER reorder — GET /overview/layouts and the frontend both walk this array
* to break ties, so reordering silently changes who gets which dashboard.
*/
export const OVERVIEW_LAYOUT_KEYS = [
"clearance",
"occ",
"operation",
"marketer",
"finance",
"executive",
] as const;
export type OverviewLayoutKey = (typeof OVERVIEW_LAYOUT_KEYS)[number];
export const OVERVIEW_LAYOUT_LABELS: Record<OverviewLayoutKey, string> = {
clearance: "Clearance & logistics dashboard",
occ: "Control centre dashboard",
operation: "Operations dashboard",
marketer: "Marketing dashboard",
finance: "Finance dashboard",
executive: "Executive dashboard",
};
export const overviewLayoutPermissionKey = (key: string): string =>
`edr_freight_app:overview:${key}:view`;
const overviewLayoutPermId = (index: number): string =>
`a4f00003-0001-4000-8000-${(index + 1).toString(16).padStart(12, "0")}`;
export const OVERVIEW_LAYOUT_PERMISSIONS: FreightPermissionSeed[] =
OVERVIEW_LAYOUT_KEYS.map((key, index) =>
perm(
overviewLayoutPermId(index),
overviewLayoutPermissionKey(key),
`Overview layout: ${OVERVIEW_LAYOUT_LABELS[key]}`,
),
);
export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
perm( perm(
@@ -464,12 +544,12 @@ export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] =
), ),
...(approveId ...(approveId
? [ ? [
perm( perm(
approveId, approveId,
`edr_freight_app:rule_engine:${resource}:approve`, `edr_freight_app:rule_engine:${resource}:approve`,
`Approve ${slug} changes`, `Approve ${slug} changes`,
), ),
] ]
: []), : []),
]; ];
}); });
@@ -572,8 +652,16 @@ export const SHIPPING_LINE_PERMISSIONS: FreightPermissionSeed[] = [
// Internal chat (Matrix/Element) — sidebar visibility + manual reconcile trigger. // Internal chat (Matrix/Element) — sidebar visibility + manual reconcile trigger.
export const CHAT_PERMISSIONS: FreightPermissionSeed[] = [ export const CHAT_PERMISSIONS: FreightPermissionSeed[] = [
perm('c9a00001-0001-4000-8000-000000000001', 'edr_freight_app:chat:view', 'Open internal chat'), perm(
perm('c9a00001-0001-4000-8000-000000000002', 'edr_freight_app:chat:sync', 'Re-run chat room/membership sync'), "c9a00001-0001-4000-8000-000000000001",
"edr_freight_app:chat:view",
"Open internal chat",
),
perm(
"c9a00001-0001-4000-8000-000000000002",
"edr_freight_app:chat:sync",
"Re-run chat room/membership sync",
),
]; ];
// D. Finance — payments + invoices // D. Finance — payments + invoices
@@ -1381,6 +1469,20 @@ export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:train_scheduling:rules_manage", "edr_freight_app:train_scheduling:rules_manage",
"Manage global scheduling rules", "Manage global scheduling rules",
), ),
// Carved out of the coarse `update` — confirming a booking's cargo loaded/
// unloaded at a yard, across import, export, and intercity movements alike
// (the same schedules/:id/bookings/:bookingId/{load,unload} + intercity
// routes serve all three directions).
perm(
"a2a00001-0001-4000-8000-000000000006",
"edr_freight_app:train_scheduling:load",
"Confirm cargo loaded (import, export, intercity)",
),
perm(
"a2a00001-0001-4000-8000-000000000007",
"edr_freight_app:train_scheduling:unload",
"Confirm cargo unloaded (import, export, intercity)",
),
]; ];
// L. Administration & settings (split from the coarse admin umbrella) // L. Administration & settings (split from the coarse admin umbrella)
@@ -1746,6 +1848,7 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [
export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
...REPORT_PERMISSIONS, ...REPORT_PERMISSIONS,
...OVERVIEW_LAYOUT_PERMISSIONS,
...CUSTOMER_PERMISSIONS, ...CUSTOMER_PERMISSIONS,
...SHIPPING_LINE_PERMISSIONS, ...SHIPPING_LINE_PERMISSIONS,
...CHAT_PERMISSIONS, ...CHAT_PERMISSIONS,
@@ -1907,6 +2010,15 @@ export const FREIGHT_PERMS = {
cancel: "edr_freight_app:train_scheduling:cancel", cancel: "edr_freight_app:train_scheduling:cancel",
reschedule: "edr_freight_app:train_scheduling:reschedule", reschedule: "edr_freight_app:train_scheduling:reschedule",
rulesManage: "edr_freight_app:train_scheduling:rules_manage", rulesManage: "edr_freight_app:train_scheduling:rules_manage",
/**
* Confirm a booking's cargo loaded/unloaded at a yard — carved out of the
* coarse `update` so load/unload can be granted independently of general
* schedule editing. Covers import, export, and intercity alike: the
* generic per-booking route and the intercity-specific one both gate on
* these same two keys.
*/
load: "edr_freight_app:train_scheduling:load",
unload: "edr_freight_app:train_scheduling:unload",
dispatch: "edr_freight_app:train_scheduling:dispatch", dispatch: "edr_freight_app:train_scheduling:dispatch",
markPaid: "edr_freight_app:train_scheduling:mark_paid", markPaid: "edr_freight_app:train_scheduling:mark_paid",
expireBooking: "edr_freight_app:train_scheduling:expire_booking", expireBooking: "edr_freight_app:train_scheduling:expire_booking",
@@ -1978,8 +2090,7 @@ export const FREIGHT_PERMS = {
// finance-level REQUEST grants (per action) and decision grants that apply // finance-level REQUEST grants (per action) and decision grants that apply
// to ANY pending request — including the holder's own. // to ANY pending request — including the holder's own.
/** Request recording an offline payment against a credit invoice. */ /** Request recording an offline payment against a credit invoice. */
invoiceMarkPaid: invoiceMarkPaid: "edr_freight_app:shipping_line_credits:invoice_mark_paid",
"edr_freight_app:shipping_line_credits:invoice_mark_paid",
/** Request voiding a credit invoice (credits return to unbilled). */ /** Request voiding a credit invoice (credits return to unbilled). */
invoiceCancel: "edr_freight_app:shipping_line_credits:invoice_cancel", invoiceCancel: "edr_freight_app:shipping_line_credits:invoice_cancel",
/** Approve any pending invoice request (mark-paid or cancel). */ /** Approve any pending invoice request (mark-paid or cancel). */
@@ -1988,8 +2099,8 @@ export const FREIGHT_PERMS = {
invoiceReject: "edr_freight_app:shipping_line_credits:invoice_reject", invoiceReject: "edr_freight_app:shipping_line_credits:invoice_reject",
}, },
chat: { chat: {
view: 'edr_freight_app:chat:view', view: "edr_freight_app:chat:view",
sync: 'edr_freight_app:chat:sync', sync: "edr_freight_app:chat:sync",
}, },
payments: { payments: {
view: "edr_freight_app:payments:view", view: "edr_freight_app:payments:view",
@@ -2292,6 +2403,7 @@ export const FREIGHT_PERMS = {
}, },
overview: { overview: {
view: "edr_freight_app:overview:view", view: "edr_freight_app:overview:view",
layout: (key: OverviewLayoutKey): string => overviewLayoutPermissionKey(key),
}, },
reports: { reports: {
view: "edr_freight_app:reports:view", view: "edr_freight_app:reports:view",
@@ -2444,7 +2556,8 @@ const FLEET_GRANULAR_KEYS: string[] = [
FREIGHT_PERMS.consignments.create, FREIGHT_PERMS.consignments.create,
]; ];
const allReportKeys = (): string[] => REPORT_KEYS.map((k) => reportPermissionKey(k)); const allReportKeys = (): string[] =>
REPORT_KEYS.map((k) => reportPermissionKey(k));
// Everyone who works the booking desk also opens the overview dashboard and // Everyone who works the booking desk also opens the overview dashboard and
// the canned reports — granted alongside bookings:view in every preset below. // the canned reports — granted alongside bookings:view in every preset below.
@@ -2512,6 +2625,8 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.trainScheduling.view, FREIGHT_PERMS.trainScheduling.view,
FREIGHT_PERMS.trainScheduling.create, FREIGHT_PERMS.trainScheduling.create,
FREIGHT_PERMS.trainScheduling.update, FREIGHT_PERMS.trainScheduling.update,
FREIGHT_PERMS.trainScheduling.load,
FREIGHT_PERMS.trainScheduling.unload,
FREIGHT_PERMS.trainScheduling.cancel, FREIGHT_PERMS.trainScheduling.cancel,
FREIGHT_PERMS.trainScheduling.reschedule, FREIGHT_PERMS.trainScheduling.reschedule,
FREIGHT_PERMS.trainScheduling.rulesManage, FREIGHT_PERMS.trainScheduling.rulesManage,
@@ -2732,6 +2847,8 @@ export const POSITION_PERMISSION_PRESETS = {
FREIGHT_PERMS.trainScheduling.view, FREIGHT_PERMS.trainScheduling.view,
FREIGHT_PERMS.trainScheduling.create, FREIGHT_PERMS.trainScheduling.create,
FREIGHT_PERMS.trainScheduling.update, FREIGHT_PERMS.trainScheduling.update,
FREIGHT_PERMS.trainScheduling.load,
FREIGHT_PERMS.trainScheduling.unload,
FREIGHT_PERMS.trainScheduling.cancel, FREIGHT_PERMS.trainScheduling.cancel,
FREIGHT_PERMS.trainScheduling.reschedule, FREIGHT_PERMS.trainScheduling.reschedule,
FREIGHT_PERMS.trainScheduling.rulesManage, FREIGHT_PERMS.trainScheduling.rulesManage,

View File

@@ -229,7 +229,18 @@ export class FreightPositionsSeeder {
return; return;
} }
await positionPermissionRepository.insert(rowsToInsert); // orIgnore, not a bare insert: the read above and this write are not
// atomic across processes — two API replicas booting together (or a
// restart racing a running boot) both see the grant missing and both
// insert it, and the loser died on UQ_87ee8f7eef7366389a02ff69f04 with
// the whole seed transaction. ON CONFLICT DO NOTHING makes the grant
// idempotent no matter who else is inserting it.
await positionPermissionRepository
.createQueryBuilder()
.insert()
.values(rowsToInsert)
.orIgnore()
.execute();
this.logger.log( this.logger.log(
`Granted ${rowsToInsert.length} permissions to position '${seed.key}'`, `Granted ${rowsToInsert.length} permissions to position '${seed.key}'`,

View File

@@ -16,6 +16,7 @@ import {
Textarea, Textarea,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { import {
Ban, Ban,
Download, Download,
@@ -32,7 +33,7 @@ import { isViewable } from "@edr/ui-common";
import { bookingsService } from "@/services/bookings.service"; import { bookingsService } from "@/services/bookings.service";
import { downloadBookingFile, fetchViewableFile } from "@/services/files.service"; import { downloadBookingFile, fetchViewableFile } from "@/services/files.service";
import { formatDateTime } from "@/lib/format"; import { formatDate, formatDateTime } from "@/lib/format";
import { extractErrorMessage } from "@/utils/errorExtractor"; import { extractErrorMessage } from "@/utils/errorExtractor";
const CURRENCIES = ["ETB", "USD"]; const CURRENCIES = ["ETB", "USD"];
@@ -75,6 +76,7 @@ export function AdditionalPaymentsTab({ bookingId, onViewFile }: AdditionalPayme
currency: string; currency: string;
action: "draft" | "send"; action: "draft" | "send";
file?: File | null; file?: File | null;
dueDate?: string | null;
}) => bookingsService.createAdditionalCharge(bookingId, p), }) => bookingsService.createAdditionalCharge(bookingId, p),
onSuccess: (next, p) => { onSuccess: (next, p) => {
toast.success(p.action === "send" ? "Charge sent to the customer" : "Draft saved"); toast.success(p.action === "send" ? "Charge sent to the customer" : "Draft saved");
@@ -203,16 +205,29 @@ function ChargeCard({
{charge.cancelReason ? `${charge.cancelReason}` : ""} {charge.cancelReason ? `${charge.cancelReason}` : ""}
</Text> </Text>
)} )}
{charge.dueAt && charge.status !== "PAID" && charge.status !== "CANCELLED" && (
<Text fz="11.5px" c="dimmed">
Due {formatDate(charge.dueAt)}
</Text>
)}
</Box> </Box>
</Group> </Group>
<Group gap={8} wrap="nowrap"> <Group gap={8} wrap="nowrap" align="flex-end" style={{ flexDirection: "column" }}>
<Text fz="14px" fw={800} c="edr-text"> <Group gap={8} wrap="nowrap">
{charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "} <Text fz="14px" fw={800} c="edr-text">
{charge.currency} {charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "}
</Text> {charge.currency}
<Badge variant="light" color={meta.color} radius="sm"> </Text>
{meta.label} <Badge variant="light" color={meta.color} radius="sm">
</Badge> {meta.label}
</Badge>
</Group>
{charge.convertedAmount != null && (
<Text fz="11.5px" c="dimmed">
{charge.convertedAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "}
{charge.convertedCurrency}
</Text>
)}
</Group> </Group>
</Group> </Group>
@@ -297,12 +312,14 @@ function AddChargeModal({
currency: string; currency: string;
action: "draft" | "send"; action: "draft" | "send";
file?: File | null; file?: File | null;
dueDate?: string | null;
}) => void; }) => void;
}) { }) {
const [reason, setReason] = useState(""); const [reason, setReason] = useState("");
const [amount, setAmount] = useState<number | string>(""); const [amount, setAmount] = useState<number | string>("");
const [currency, setCurrency] = useState("ETB"); const [currency, setCurrency] = useState("ETB");
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [dueDate, setDueDate] = useState<Date | null>(null);
const valid = reason.trim().length > 0 && Number(amount) > 0; const valid = reason.trim().length > 0 && Number(amount) > 0;
@@ -311,11 +328,23 @@ function AddChargeModal({
setAmount(""); setAmount("");
setCurrency("ETB"); setCurrency("ETB");
setFile(null); setFile(null);
setDueDate(null);
}; };
const submit = (action: "draft" | "send") => { const submit = (action: "draft" | "send") => {
if (!valid) return; if (!valid) return;
onSubmit({ reason: reason.trim(), amount: Number(amount), currency, action, file }); onSubmit({
reason: reason.trim(),
amount: Number(amount),
currency,
action,
file,
// Local calendar date, not a UTC-shifted ISO timestamp — toISOString() can
// roll the date back a day for evening local time in a positive-offset zone.
dueDate: dueDate
? `${dueDate.getFullYear()}-${String(dueDate.getMonth() + 1).padStart(2, "0")}-${String(dueDate.getDate()).padStart(2, "0")}`
: null,
});
}; };
return ( return (
@@ -355,6 +384,14 @@ function AddChargeModal({
w={100} w={100}
/> />
</Group> </Group>
<DateInput
label="Due date"
placeholder="Defaults to 14 days after sending"
value={dueDate}
onChange={(v) => setDueDate(v ? new Date(v) : null)}
minDate={new Date()}
clearable
/>
<FileButton onChange={setFile} accept="application/pdf,image/*"> <FileButton onChange={setFile} accept="application/pdf,image/*">
{(props) => ( {(props) => (
<Button <Button

View File

@@ -873,15 +873,10 @@ export default function GlCreateBookingForm() {
// Only a customs (Path B) instance being COMPLETED by GL can use the shared // Only a customs (Path B) instance being COMPLETED by GL can use the shared
// wagon: it is GL, not the customer, who links the two bookings. Anything else // wagon: it is GL, not the customer, who links the two bookings. Anything else
// keeps the historical hard block on odd 20ft. // falls through to the server's automatic consolidation gate.
// const oddConsolidationAvailable = Boolean(
// Switched OFF for now: consolidation is built end to end (toggle, parent completeBookingId && isContainer && contract?.customsClearingEnabled,
// picker, split entry, paired pricing, approval gate) but not in use, so an );
// odd 20ft total is rejected outright instead of offering the shared wagon.
// Drop the `false &&` to bring the whole flow back.
const oddConsolidationAvailable =
false &&
Boolean(completeBookingId && isContainer && contract?.customsClearingEnabled);
// Auto-on: entering an odd 20ft total opens the consolidation panel by itself, // Auto-on: entering an odd 20ft total opens the consolidation panel by itself,
// once. GL can still switch it off — then odd is blocked exactly as before. // once. GL can still switch it off — then odd is blocked exactly as before.
@@ -970,11 +965,11 @@ export default function GlCreateBookingForm() {
!cargoDescriptionError !cargoDescriptionError
: !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer; : !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer;
// Consolidation (sharing the wagon with another customer's odd booking) is // COMPLETION never blocks on an odd 20ft total: a customs instance can share
// built but switched off for now, so an odd 20ft total always blocks — the // the wagon via the manual pair (consolidationActive), and anything else is
// shared wagon no longer resolves the unpaired container. Flip this back to // auto-paired or parked as PENDING_CONSOLIDATION by the server's
// `hasOdd20ft && !consolidationActive` to re-enable the shared-wagon path. // consolidation gate. Creating a booking from scratch keeps the block.
const oddBlocksSubmit = hasOdd20ft; const oddBlocksSubmit = hasOdd20ft && !completeBookingId;
// Partner side: a linked partner must be picked, carry an odd 20ft count of // Partner side: a linked partner must be picked, carry an odd 20ft count of
// its own (odd + odd = even fills the wagon) and have complete unit details. // its own (odd + odd = even fills the wagon) and have complete unit details.

View File

@@ -1,6 +1,3 @@
import type { AuthUser } from "@/auth/types";
import { getPositionKeys } from "@/lib/permissions";
/** One overview composition. Every backoffice user lands on exactly one of these. */ /** One overview composition. Every backoffice user lands on exactly one of these. */
export type OverviewLayoutKey = export type OverviewLayoutKey =
| "executive" | "executive"
@@ -20,80 +17,35 @@ export const OVERVIEW_LAYOUT_LABEL: Record<OverviewLayoutKey, string> = {
}; };
/** /**
* Position/role key → layout, in match priority order: a user holding several * Priority order: a caller who holds more than one of the six
* of these keys gets the first match, so the specific operational view wins * `edr_freight_app:overview:<key>:view` permissions gets the FIRST match
* over the broad executive one. Roles are matched alongside positions because * here — the specific operational view wins over the broad executive one.
* the IAM payload models the GL desks as positions (`ethiopian_gl`) on some * Mirrors `OVERVIEW_LAYOUT_KEYS` in the API's freight-permissions.registry.ts
* accounts and as roles (`edr_gl_ethiopia`) on others — see `getPositionKeys`. * bit for bit; keep the two in sync if this ever changes.
*
* The `edr_freight_app/…` keys are the org's real position keys (root desks and
* their sub-positions) as configured under Unit → Departments. They are typed
* by hand in the Add/Edit Department form, so a new sub-position appears here
* only once someone adds it — unmapped keys fall through to `executive`.
*/ */
const ROLE_LAYOUTS: Array<[key: string, layout: OverviewLayoutKey]> = [ const LAYOUT_PRIORITY: OverviewLayoutKey[] = [
// ── Clearance & logistics: both GL desks, root and sub-positions ────────── "clearance",
["ethiopian_gl", "clearance"], "occ",
["edr_freight_app/gl_003", "clearance"], // Ethiopian GL Chief "operation",
["edr_freight_app/off_001", "clearance"], // Ethiopian GL Director "marketer",
["edr_freight_app/off_0056", "clearance"], // Ethiopian GL Officer "finance",
["djibouti_gl", "clearance"], "executive",
["edr_freight_app/dj_gl_001", "clearance"], // Djibouti GL Director
["edr_freight_app/dj_gl_002", "clearance"], // Djibouti GL Chief
["edr_freight_app/dj_gl_003", "clearance"], // Djibouti GL Officer
["edr_gl_ethiopia", "clearance"], // legacy role form
["edr_gl_djibouti", "clearance"], // legacy role form
// ── Control centre ───────────────────────────────────────────────────────
["edr_freight_app/occ_001", "occ"], // OCC
["edr_freight_app/occ_005", "occ"], // OCC Director
["edr_line_staff", "occ"], // legacy role form
// ── Operations: operations desk, track & machinery, rolling stock ─────────
["edr_freight_app/opn", "operation"], // Operation
["edr_freight_app/opcf", "operation"], // Operation Chief
["edr_freight_app/opdr", "operation"], // Operation Director
["edr_freight_app/opco", "operation"], // Operation Officer
["edr_freight_app/opp_005", "operation"], // Operation Dispatcher
["edr_freight_app/opp_0067", "operation"], // Gelan Operation Director
["edr_freight_app/track_001", "operation"], // Track And Machinery
["edr_freight_app/ttk_001", "operation"], // Track Director
["edr_freight_app/tto_001", "operation"], // Track Operator
["edr_freight_app/rool_001", "operation"], // Rolling Stock
["edr_freight_app/rl_003", "operation"], // Rolling Stock Director
["edr_freight_app/rl_009", "operation"], // Rolling Stock Team Lead
["edr_freight_app/rl_0090", "operation"], // Rolling Stock Dispatcher
["operation", "operation"],
["operations_chief", "operation"],
["dispatcher", "operation"],
["truck_machinery_chief", "operation"],
["edr_operations_officer", "operation"], // legacy role form
// ── Marketing ────────────────────────────────────────────────────────────
["edr_freight_app/edr_test_org_0022", "marketer"], // Commercial Marketing
["edr_freight_app/edr_test_org_00567", "marketer"], // Marketing Director
["edr_freight_app/edr_test_org_0054", "marketer"], // Marketing Chief
["edr_freight_app/edr_test_org_0013", "marketer"], // Marketing Officer
["marketer", "marketer"],
["edr_marketing", "marketer"], // legacy role form
// ── Finance ──────────────────────────────────────────────────────────────
["edr_freight_app/finance", "finance"],
["edr_finance", "finance"], // legacy role form
// ── Executive: org-wide desks with no operational queue of their own ──────
["ceo", "executive"],
["director", "executive"],
["chief", "executive"],
["edr_ceo", "executive"], // legacy role form
["edr_director", "executive"], // legacy role form
["edr_org_manager", "executive"], // legacy role form
]; ];
/** Unmapped keys (superadmin, IAM admins, Safety, new positions) keep the executive layout. */ /**
* Which layout to render, given the keys `GET /overview/layouts` said the
* caller may see — the endpoint already filtered those by permission, so
* this only breaks the tie when a caller holds more than one. Same shape as
* the Reports page trusting `GET /reports`'s catalog rather than re-deriving
* access from permission keys client-side.
*
* Empty/unmapped falls back to the executive layout — same default the old
* role/position-key table used for superadmin, IAM admins, and any position
* that hasn't been granted one of these permissions yet.
*/
export function resolveOverviewLayout( export function resolveOverviewLayout(
user: AuthUser | null | undefined, allowed: OverviewLayoutKey[] | undefined,
): OverviewLayoutKey { ): OverviewLayoutKey {
const held = new Set(getPositionKeys(user)); const held = new Set(allowed ?? []);
return ROLE_LAYOUTS.find(([key]) => held.has(key))?.[1] ?? "executive"; return LAYOUT_PRIORITY.find((key) => held.has(key)) ?? "executive";
} }

View File

@@ -0,0 +1,194 @@
import {
Badge,
Button,
Checkbox,
Group,
Pagination,
Paper,
Stack,
Table,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { Link2, MapPin, PackageOpen, User } from "lucide-react";
import { useState } from "react";
import { api } from "@/services/api";
interface Props {
trainId: string;
/** Staff may attach and the train is editable (not out on a run). */
canAttach: boolean;
attachPending: boolean;
onAttach: (wagonIds: string[]) => void;
}
/**
* "Detached wagons" tab: wagons last detached from THIS train that are still
* loose — with when, where and by whom they were detached — so staff can pick
* them straight back onto the consist without hunting through the global pool.
*/
export default function DetachedWagonsPanel({
trainId,
canAttach,
attachPending,
onAttach,
}: Props) {
const [page, setPage] = useState(1);
const query = useQuery(
api.trainBuilder.detachedWagons.queryOptions({
input: { id: trainId, page, pageSize: 20 },
enabled: Boolean(trainId),
// Keep the previous page on screen while the next one loads.
placeholderData: (prev) => prev,
}),
);
const rows = query.data?.items ?? [];
const totalPages = Math.max(1, query.data?.meta.totalPages ?? 1);
// Selection is page-scoped in the header checkbox but survives paging, so
// staff can gather wagons across pages into one attach.
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
const allSelected = rows.length > 0 && rows.every((r) => selected.has(r.wagonId));
const toggle = (wagonId: string, checked: boolean) =>
setSelected((prev) => {
const next = new Set(prev);
if (checked) next.add(wagonId);
else next.delete(wagonId);
return next;
});
return (
<Paper radius="xl" p="lg" withBorder>
<Stack gap="lg">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="md" align="center" wrap="nowrap">
<ThemeIcon size={44} radius="md" variant="light" color="orange">
<PackageOpen size={22} />
</ThemeIcon>
<Stack gap={2}>
<Text fw={700} fz="lg">
Detached wagons
</Text>
<Text size="sm" c="dimmed">
Wagons that left this train and are still loose select and
attach them back in one click.
</Text>
</Stack>
</Group>
{canAttach ? (
<Button
leftSection={<Link2 size={16} />}
disabled={selected.size === 0}
loading={attachPending}
onClick={() => {
onAttach([...selected]);
setSelected(new Set());
}}
>
Attach {selected.size || ""} wagon{selected.size === 1 ? "" : "s"}
</Button>
) : null}
</Group>
{query.isLoading ? (
<Text size="sm" c="dimmed" ta="center" py="md">
Loading detached wagons
</Text>
) : rows.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
No loose wagons were detached from this train detach history starts
being recorded from now on.
</Text>
) : (
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
{canAttach ? (
<Table.Th w={36}>
<Checkbox
checked={allSelected}
indeterminate={selected.size > 0 && !allSelected}
onChange={(e) =>
setSelected(
e.currentTarget.checked
? new Set(rows.map((r) => r.wagonId))
: new Set(),
)
}
/>
</Table.Th>
) : null}
<Table.Th>Wagon</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Now standing at</Table.Th>
<Table.Th>Last detached</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((r) => (
<Table.Tr key={r.wagonId}>
{canAttach ? (
<Table.Td>
<Checkbox
checked={selected.has(r.wagonId)}
onChange={(e) => toggle(r.wagonId, e.currentTarget.checked)}
/>
</Table.Td>
) : null}
<Table.Td>
<Text fw={600} size="sm" ff="monospace">
{r.wagonNumber}
</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color="gray">
{r.wagonTypeCode ?? "—"}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">{r.currentYardLabel ?? "No yard"}</Text>
</Table.Td>
<Table.Td>
<Group gap="md" wrap="wrap">
<Tooltip label={new Date(r.detachedAt).toLocaleString()}>
<Text size="sm">{new Date(r.detachedAt).toLocaleDateString()}</Text>
</Tooltip>
{r.detachedYardLabel ? (
<Group gap={4} wrap="nowrap">
<MapPin size={12} />
<Text size="xs" c="dimmed">
at {r.detachedYardLabel}
</Text>
</Group>
) : null}
{r.detachedBy ? (
<Group gap={4} wrap="nowrap">
<User size={12} />
<Text size="xs" c="dimmed">
by {r.detachedBy}
</Text>
</Group>
) : null}
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
{totalPages > 1 ? (
<Group justify="space-between">
<Text size="xs" c="dimmed">
{query.data?.meta.total ?? 0} wagon(s) · selection carries across pages
</Text>
<Pagination size="sm" value={page} onChange={setPage} total={totalPages} />
</Group>
) : null}
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,140 @@
import { Badge, Group, Pagination, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeftRight, History, MapPin, Minus, Plus, TrainFront, User } from "lucide-react";
import { useState } from "react";
import { api } from "@/services/api";
import type { TrainHistoryEntry } from "@/services/trainBuilder.service";
const PAGE_SIZE = 20;
const ACTION_META: Record<
TrainHistoryEntry["action"],
{ label: string; color: string; icon: typeof Plus }
> = {
ADD: { label: "Wagon attached", color: "edr-green", icon: Plus },
REMOVE: { label: "Wagon detached", color: "red", icon: Minus },
SWITCH: { label: "Wagon switched", color: "blue", icon: ArrowLeftRight },
};
/**
* "History" tab of the train-builder detail page: every wagon ever attached,
* detached or switched on this built train — builder edits and trip events
* (real cuts, mid-route couples, consist adjustments) alike, newest first.
*/
export default function TrainHistoryPanel({ trainId }: { trainId: string }) {
const [page, setPage] = useState(1);
const historyQuery = useQuery(
api.trainBuilder.history.queryOptions({
input: { id: trainId, page, pageSize: PAGE_SIZE },
enabled: Boolean(trainId),
// Keep the previous page on screen while the next one loads.
placeholderData: (prev) => prev,
}),
);
const entries = historyQuery.data?.items ?? [];
const totalPages = Math.max(1, historyQuery.data?.meta.totalPages ?? 1);
const total = historyQuery.data?.meta.total ?? 0;
return (
<Paper radius="xl" p="lg" withBorder>
<Stack gap="lg">
<Group gap="md" align="center" wrap="nowrap">
<ThemeIcon size={44} radius="md" variant="light" color="edr-green">
<History size={22} />
</ThemeIcon>
<Stack gap={2}>
<Text fw={700} fz="lg">
Wagon history
</Text>
<Text size="sm" c="dimmed">
Who attached, detached or switched which wagon on this train from
the builder and from its trips newest first.
</Text>
</Stack>
</Group>
{historyQuery.isLoading ? (
<Text size="sm" c="dimmed" ta="center" py="md">
Loading history
</Text>
) : entries.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
No wagon changes recorded yet for this train.
</Text>
) : (
<Timeline bulletSize={26} lineWidth={2} color="edr-green">
{entries.map((entry) => {
const meta = ACTION_META[entry.action] ?? ACTION_META.ADD;
const Icon = meta.icon;
return (
<Timeline.Item
key={entry.id}
bullet={<Icon size={13} />}
color={meta.color}
title={
<Group gap="xs" wrap="nowrap">
<Badge size="sm" variant="light" color={meta.color}>
{meta.label}
</Badge>
{entry.subject ? (
<Text size="sm" fw={600} ff="monospace">
{entry.subject}
</Text>
) : null}
{entry.scheduleReference ? (
<Badge
size="sm"
variant="light"
color="blue"
leftSection={<TrainFront size={10} />}
>
{entry.scheduleReference}
</Badge>
) : (
<Badge size="sm" variant="light" color="gray">
Builder
</Badge>
)}
</Group>
}
>
<Group gap="md" mt={2}>
<Text size="xs" c="dimmed">
{new Date(entry.occurredAt).toLocaleString()}
</Text>
{entry.yardLabel ? (
<Group gap={4} wrap="nowrap">
<MapPin size={12} />
<Text size="xs" c="dimmed">
at {entry.yardLabel}
</Text>
</Group>
) : null}
{entry.actor ? (
<Group gap={4} wrap="nowrap">
<User size={12} />
<Text size="xs" c="dimmed">
{entry.actor}
</Text>
</Group>
) : null}
</Group>
</Timeline.Item>
);
})}
</Timeline>
)}
{totalPages > 1 ? (
<Group justify="space-between">
<Text size="xs" c="dimmed">
{total} change(s)
</Text>
<Pagination size="sm" value={page} onChange={setPage} total={totalPages} />
</Group>
) : null}
</Stack>
</Paper>
);
}

Some files were not shown because too many files have changed in this diff Show More