diff --git a/EDR-Freight-User-Guide.pdf b/EDR-Freight-User-Guide.pdf new file mode 100644 index 000000000..66512875f Binary files /dev/null and b/EDR-Freight-User-Guide.pdf differ diff --git a/apps/edr-freight-api/scripts/run-legboard-tests.ts b/apps/edr-freight-api/scripts/run-legboard-tests.ts new file mode 100644 index 000000000..a68ae7239 --- /dev/null +++ b/apps/edr-freight-api/scripts/run-legboard-tests.ts @@ -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(); +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(); + 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(); + 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); }); diff --git a/apps/edr-freight-api/scripts/run-s45-scenario.ts b/apps/edr-freight-api/scripts/run-s45-scenario.ts new file mode 100644 index 000000000..dce4b38e1 --- /dev/null +++ b/apps/edr-freight-api/scripts/run-s45-scenario.ts @@ -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; +}> { + 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(); + 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> { + 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): Promise { + 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(); + 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) => { + 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> = []; + // 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); }); diff --git a/apps/edr-freight-api/scripts/run-wagon-gate-tests.ts b/apps/edr-freight-api/scripts/run-wagon-gate-tests.ts new file mode 100644 index 000000000..46b752373 --- /dev/null +++ b/apps/edr-freight-api/scripts/run-wagon-gate-tests.ts @@ -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; + codes: Map; +}> { + 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(); + 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 { + 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): Promise { + 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(); + 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); +}); diff --git a/apps/edr-freight-api/scripts/seed-s45-scenario.cjs b/apps/edr-freight-api/scripts/seed-s45-scenario.cjs new file mode 100644 index 000000000..39c3f1125 --- /dev/null +++ b/apps/edr-freight-api/scripts/seed-s45-scenario.cjs @@ -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); }); diff --git a/apps/edr-freight-api/scripts/seed-wagon-gate-testcases.cjs b/apps/edr-freight-api/scripts/seed-wagon-gate-testcases.cjs new file mode 100644 index 000000000..e37dc6536 --- /dev/null +++ b/apps/edr-freight-api/scripts/seed-wagon-gate-testcases.cjs @@ -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); +}); diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index f9eab4d39..68bbdcba4 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -75,7 +75,7 @@ export const TrainSchedulingView = () => BookingStaff(FREIGHT_PERMS.trainScheduling.view); // 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. export const TrainSchedulingCreate = () => BookingStaff(FREIGHT_PERMS.trainScheduling.create); @@ -83,6 +83,18 @@ export const TrainSchedulingCreate = () => export const TrainSchedulingUpdate = () => 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 = () => BookingStaff(FREIGHT_PERMS.trainScheduling.cancel); diff --git a/apps/edr-freight-api/src/migrations/3650000000000-AdditionalChargeDueAt.ts b/apps/edr-freight-api/src/migrations/3650000000000-AdditionalChargeDueAt.ts new file mode 100644 index 000000000..8dcda8181 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3650000000000-AdditionalChargeDueAt.ts @@ -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 { + await queryRunner.query(` + ALTER TABLE "freight"."additional_charge" + ADD COLUMN IF NOT EXISTS "due_at" timestamptz + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "freight"."additional_charge" DROP COLUMN IF EXISTS "due_at" + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3650000000000-SchedulePlannedWagonCutYards.ts b/apps/edr-freight-api/src/migrations/3650000000000-SchedulePlannedWagonCutYards.ts new file mode 100644 index 000000000..d061cd9e0 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3650000000000-SchedulePlannedWagonCutYards.ts @@ -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 { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS planned_wagon_cut_yards jsonb + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS planned_wagon_cut_yards + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3660000000000-SchedulePlannedWagonCouples.ts b/apps/edr-freight-api/src/migrations/3660000000000-SchedulePlannedWagonCouples.ts new file mode 100644 index 000000000..ff87201f9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3660000000000-SchedulePlannedWagonCouples.ts @@ -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 { + 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 { + 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 + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3670000000000-AdjustmentLogNullableSchedule.ts b/apps/edr-freight-api/src/migrations/3670000000000-AdjustmentLogNullableSchedule.ts new file mode 100644 index 000000000..6bd2bdd62 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3670000000000-AdjustmentLogNullableSchedule.ts @@ -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 { + await queryRunner.query(` + ALTER TABLE freight.schedule_wagon_adjustment_logs + ALTER COLUMN train_schedule_id DROP NOT NULL + `); + } + + public async down(_queryRunner: QueryRunner): Promise { + // No-op: restoring NOT NULL would fail on any builder-origin rows written + // while this migration was live, re-introducing the outage it fixed. + } +} diff --git a/apps/edr-freight-api/src/migrations/3680000000000-WagonAllocationSlotIndex.ts b/apps/edr-freight-api/src/migrations/3680000000000-WagonAllocationSlotIndex.ts new file mode 100644 index 000000000..c5c855fb5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3680000000000-WagonAllocationSlotIndex.ts @@ -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 { + 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 { + await queryRunner.query(` + DROP INDEX IF EXISTS freight.idx_wagon_booking_allocations_slot + `); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index c5cc9a81e..a3ed3e417 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -1,5 +1,6 @@ import { Freight } from "@edr/types"; +import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "../bookings/entities/booking-wagon-cancellation.entity"; import { BillingService } from "./billing.service"; /** @@ -1033,3 +1034,73 @@ describe("BillingService.document", () => { expect(render).not.toHaveBeenCalled(); }); }); + +/** + * The pay-window guard belongs to the freight invoice. A wagon-cancellation fee + * rides source=booking but is raised on an already-PAID booking, so it inherits + * a deadline that has long passed — guarding it would make the fee permanently + * unsettleable. + */ +describe("BillingService.confirmOfflinePayment pay-window guard", () => { + const PAST = new Date(Date.now() - 86_400_000); + + function makeService(invoiceType: string) { + const invoice = { + id: "inv-1", + source: Freight.InvoiceSource.Booking, + sourceId: "booking-1", + type: invoiceType, + currency: "ETB", + status: Freight.InvoiceStatus.Issued, + balanceAmount: 500, + }; + const recordPayment = jest.fn().mockResolvedValue(invoice); + const dataSource = { + getRepository: () => ({ + findOne: async () => ({ id: "booking-1", paymentDeadline: PAST }), + }), + }; + const service = new BillingService( + dataSource as never, + { findById: async () => invoice } as never, + {} as never, + makeEvents() as never, + {} as never, + {} as never, + {} as never, + { upload: async () => ({ id: "file-1", name: "slip.pdf" }) } as never, + { get: () => undefined } as never, + { isEnabled: async () => true } as never, + ); + (service as unknown as { recordPayment: unknown }).recordPayment = + recordPayment; + return { service, recordPayment }; + } + + const slip = { originalname: "slip.pdf" } as never; + + it("refuses a freight invoice once the pay window has closed", async () => { + const { service } = makeService("PREPAID"); + await expect( + service.confirmOfflinePayment("inv-1", slip, {}), + ).rejects.toThrow(/payment window has closed/i); + }); + + it("settles a wagon-cancellation fee despite the closed window", async () => { + const { service, recordPayment } = makeService( + WAGON_CANCEL_FEE_INVOICE_TYPE, + ); + await service.confirmOfflinePayment("inv-1", slip, {}); + expect(recordPayment).toHaveBeenCalledWith( + "inv-1", + expect.objectContaining({ amount: 500, method: "BANK_TRANSFER" }), + ); + }); + + it("still requires the bank slip for a cancellation fee", async () => { + const { service } = makeService(WAGON_CANCEL_FEE_INVOICE_TYPE); + await expect( + service.confirmOfflinePayment("inv-1", undefined, {}), + ).rejects.toThrow(/slip file is required/i); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 247a1dad5..39c5a36ad 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -14,6 +14,7 @@ import { DataSource, EntityManager, In, SelectQueryBuilder } from "typeorm"; import { Booking } from "../bookings/entities/booking.entity"; import { AdditionalCharge } from "../bookings/entities/additional-charge.entity"; +import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "../bookings/entities/booking-wagon-cancellation.entity"; // Entity-only import (no module edge): portal reads resolve shipping-line // payers straight off the table. import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity"; @@ -31,6 +32,7 @@ import { InvoiceDocumentService, pngDataUrl, } from "./documents/invoice-document.service"; +import { INVOICE_SORT_COLUMNS } from "./dto/filter-invoice.dto"; import { InvoiceLine } from "./entities/invoice-line.entity"; import { Invoice, InvoicePayment } from "./entities/invoice.entity"; import { InvoiceLineRepository } from "./invoice-line.repository"; @@ -98,6 +100,31 @@ export interface RecordPaymentInput { } /** 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; /** 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. */ private applyInvoiceFilters( qb: SelectQueryBuilder, - filter: { - companyId?: string; - status?: Freight.InvoiceStatus; - search?: string; - tradeDirections?: string[]; - }, + filter: InvoiceListFilters, ) { if (filter.companyId) { qb.andWhere("invoice.companyId = :companyId", { @@ -259,6 +281,57 @@ export class BillingService { if (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) { // Searches what the row actually shows: its number, who it bills, and // the source record behind it (booking reference, GRN, shipping line). @@ -300,14 +373,11 @@ export class BillingService { } async findAllPaginated( - filter: { - companyId?: string; - status?: Freight.InvoiceStatus; - search?: string; + filter: InvoiceListFilters & { page?: number; pageSize?: number; - /** Per-user trade-direction scope, applied via the source booking. */ - tradeDirections?: string[]; + sortBy?: string; + sortOrder?: "ASC" | "DESC"; } = {}, ): Promise<{ items: InvoiceListRow[]; total: number }> { const page = filter.page && filter.page > 0 ? filter.page : 1; @@ -318,7 +388,14 @@ export class BillingService { .getRepository(Invoice) .createQueryBuilder("invoice") .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) .take(pageSize); @@ -459,12 +536,7 @@ export class BillingService { * visible page. */ async collectedSummary( - filter: { - companyId?: string; - status?: Freight.InvoiceStatus; - search?: string; - tradeDirections?: string[]; - } = {}, + filter: InvoiceListFilters = {}, ): Promise> { const qb = this.dataSource .getRepository(Invoice) @@ -639,7 +711,14 @@ export class BillingService { throw new BadRequestException("The bank payment slip file is required."); } - if (invoice.source === "booking") { + // The pay window belongs to the freight invoice. A wagon-cancellation fee + // rides source=booking but is raised on an ALREADY-PAID booking, so it + // inherits a deadline that has long passed — guarding it would make the fee + // permanently unsettleable. + if ( + invoice.source === "booking" && + invoice.type !== WAGON_CANCEL_FEE_INVOICE_TYPE + ) { const booking = await this.dataSource.getRepository(Booking).findOne({ where: { id: invoice.sourceId }, select: ["id", "paymentDeadline"], diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts new file mode 100644 index 000000000..55e6b19d2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts @@ -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) => { + 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"]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts index 91327946c..aec6e4ac0 100644 --- a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts @@ -2,14 +2,43 @@ import { Freight } from "@edr/types"; import { ApiPropertyOptional } from "@nestjs/swagger"; import { Transform } from "class-transformer"; import { + IsArray, + IsBoolean, + IsDateString, IsIn, IsInt, + IsNumber, IsOptional, IsString, IsUUID, Min, } 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 = { + 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 { @ApiPropertyOptional({ default: 1 }) @IsOptional() @@ -40,10 +69,97 @@ export class FilterInvoiceDto { @IsIn(Object.values(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"] }) @IsOptional() @Transform(({ value }: { value: unknown }) => String(value).toUpperCase()) @IsIn(["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"; } diff --git a/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts b/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts index bb5836d1a..012d5f8db 100644 --- a/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts @@ -1,6 +1,7 @@ import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { DataSource, EntityManager } from 'typeorm'; +import { ExchangeService } from '@edr/api-common'; import { Freight, NotificationAudience, NotificationType } from '@edr/types'; import { BillingService, InvoiceEventPayload } from '../billing/billing.service'; @@ -35,6 +36,7 @@ export class AdditionalChargeService { private readonly repository: AdditionalChargeRepository, private readonly bookingsRepository: BookingsRepository, private readonly filesService: FilesService, + private readonly exchangeService: ExchangeService, private readonly billing: BillingService, private readonly bookingsService: BookingsService, private readonly notifications: NotificationsService, @@ -74,6 +76,7 @@ export class AdditionalChargeService { reason: dto.reason.trim(), amount: dto.amount.toFixed(2), currency: dto.currency.trim().toUpperCase(), + dueAt: dto.dueDate ? new Date(dto.dueDate) : null, status: 'DRAFT', createdByStaffId: staffId, }), @@ -132,6 +135,8 @@ export class AdditionalChargeService { companyId: booking.companyId, companyProfileId: booking.companyProfileId, currency: charge.currency, + // Unset falls through to BillingService's own DEFAULT_DUE_DAYS (14). + dueAt: charge.dueAt ?? undefined, lines: [ { chargeType: 'ADDITIONAL_CHARGE', @@ -254,9 +259,12 @@ export class AdditionalChargeService { ? await this.dataSource.getRepository(Invoice).find({ where: invoiceIds.map((id) => ({ id })) }) : []; 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) => { const file = filesByCharge.get(r.id)?.[0]; + const fx = convertedById.get(r.id) ?? null; return { id: r.id, bookingId: r.bookingId, @@ -264,6 +272,9 @@ export class AdditionalChargeService { status: r.status, amount: Number(r.amount), 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, invoiceId: r.invoiceId ?? 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; + } + } } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts index 6496208e7..ada450c50 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts @@ -63,7 +63,7 @@ describe('BookingTransitionService — paired staff decisions', () => { 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 cancel = jest .spyOn(service, 'cancel') @@ -73,21 +73,23 @@ describe('BookingTransitionService — paired staff decisions', () => { reason: 'customer withdrew', }); - expect(cancel).toHaveBeenNthCalledWith(1, 'b-1', 'customer withdrew'); - expect(cancel).toHaveBeenNthCalledWith(2, 'b-2', 'customer withdrew'); + expect(cancel).toHaveBeenCalledTimes(1); + expect(cancel).toHaveBeenCalledWith('b-1', 'customer withdrew'); }); it('propagates a failure on the second half so neither is committed', async () => { const { service, dataSource } = makeService(paired); jest - .spyOn(service, 'cancel') + .spyOn(service, 'acceptIntake') .mockImplementationOnce(async (id) => ({ id }) as Booking) .mockImplementationOnce(async () => { throw new Error('partner is already in transit'); }); 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'); // Both halves ran inside one transaction, so the throw rolls the first back. diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 72261627e..399f14123 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -91,28 +91,10 @@ export class BookingTransitionService { /** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */ private async assert20ftPairable(booking: Booking): Promise { - // Parity gate. 20ft ride two per wagon, so an odd total leaves one container - // that cannot be placed. Consolidation (pairing it with another customer's - // odd booking) is built end to end but switched off for now, so an odd total - // is rejected here rather than parked for a partner. - // 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}).`, - ); - } - + // Odd 20ft totals are not rejected here: runConsolidationOnSubmit (called + // right after this gate) auto-pairs the odd leftover with another + // customer's odd booking or parks the booking as PENDING_CONSOLIDATION. + // Only the weight-pairing rule hard-blocks. const violations = await this.containerValidationService.validate20ftPairing(booking); if (violations.length) { @@ -456,11 +438,42 @@ export class BookingTransitionService { async cancelHold(bookingId: string, reason?: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ["SELECTED_FOR_BATCH"]); - if (booking.consolidationPartnerId) { - throw new BadRequestException( - "This booking shares a consolidated wagon with another booking — " + - "contact support to cancel it.", + // Consolidated pair: the shared wagon dies with this hold. An unpaid + // partner's hold is released with it (both cancel, no fee); a PAID partner + // keeps the whole wagon and this canceller owes the cancellation fee. + 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( 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 => { switch (decision) { case "accept": @@ -525,11 +549,6 @@ export class BookingTransitionService { ); } return this.acceptIntake(id, actorId, Number(options.validityDays)); - case "cancel": - return this.cancel( - id, - options.reason ?? "Cancelled with its consolidation partner", - ); case "operationAccept": return this.reviewOperationRequest(id, "ACCEPT", actorId, { note: options.note, @@ -566,8 +585,53 @@ export class BookingTransitionService { "PENDING_APPROVAL", "CONTRACT_READY", "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( bookingId, reason, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index 0b4315d62..c1f4114a7 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -7,6 +7,7 @@ import { Logger, NotFoundException, } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; import { ExchangeService } from '@edr/api-common'; import { Freight, NotificationAudience, NotificationType } from '@edr/types'; import { DataSource, EntityManager, In, IsNull } from 'typeorm'; @@ -35,6 +36,7 @@ import { import { BookingsRepository } from './bookings.repository'; import { RebookCancelledWagonsDto, + RebookContainerLineDto, RequestWagonCancellationDto, } from './dto/wagon-cancellation.dto'; import { Booking } from './entities/booking.entity'; @@ -44,8 +46,11 @@ import { BookingWagonCancellation, CancelledQuantities, CancelledUnitSnapshot, + WAGON_CANCEL_FEE_INVOICE_TYPE, } from './entities/booking-wagon-cancellation.entity'; +export { WAGON_CANCEL_FEE_INVOICE_TYPE }; + /** * rates.rate_type of the cancellation fee — an existing rate-engine type * (trigger CANCELLATION, never auto-applied to booking pricing). Staff @@ -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. */ const sizeFtOf = (size: string | number | null | undefined): number => parseInt(String(size ?? ''), 10); -/** invoices.type of the fee invoice — the settlement branch key in BookingInvoiceService. */ -export const WAGON_CANCEL_FEE_INVOICE_TYPE = 'WAGON_CANCEL_FEE'; const round2 = (n: number): number => Math.round(n * 100) / 100; const round3 = (n: number): number => Math.round(n * 1000) / 1000; @@ -140,7 +143,29 @@ export class BookingWagonCancellationService { creditAmount: number; }> { 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); return { wagons: cut.wagons, @@ -158,6 +183,24 @@ export class BookingWagonCancellationService { userId?: string, ): Promise { 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); if (open) { 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 feeAmount = fee.amount; const creditAmount = this.creditFor(booking, cut.wagons); @@ -280,6 +326,253 @@ export class BookingWagonCancellationService { 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 { + 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 { + 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 { + if (booking.freightType === 'CONTAINER') { + const lines = await this.dataSource.getRepository(BookingContainer).find({ + where: { bookingId: booking.id }, + }); + const bySize = new Map(); + 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 { + 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 ───────────────────────────────────────────────────────── /** @@ -405,8 +698,8 @@ export class BookingWagonCancellationService { booking, whole ? 'Booking cancelled — credit available' : 'Wagon cancellation confirmed', whole - ? `All wagons 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 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 it on any coming train day.`, ); } this.logger.log( @@ -454,22 +747,19 @@ export class BookingWagonCancellationService { `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); if (!source) throw new NotFoundException(`Booking ${row.bookingId} not found.`); if (!source.contractId) { throw new BadRequestException('The original booking has no contract to rebook under.'); } - // Friendly pre-check; createUnderContract re-asserts inside its own guards. - 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); + const createDto = this.buildRebookDto(row, dto.scheduledDate, dto.containers); // Same currency as the source booking — the credit is in it. createDto.paymentCurrency = source.paymentCurrency ?? undefined; const created = await this.contractBooking.createUnderContract( @@ -479,6 +769,9 @@ export class BookingWagonCancellationService { // System actor: carries the create-booking key so the GL gate passes on // Path B (customs-clearance) contracts; harmless on Path A. { 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; @@ -1162,11 +1455,25 @@ export class BookingWagonCancellationService { private buildRebookDto( row: BookingWagonCancellation, scheduledDate: string, + overrides?: RebookContainerLineDto[], ): CreateBookingUnderContractDto { const dto: CreateBookingUnderContractDto = { scheduledDate }; const q = row.cancelledQuantities; if (q.bySize && Object.keys(q.bySize).length) { + // Unit overrides may rename containers, change seals and VGM — but the + // cancelled sizes and quantities are the contract of the credit: a size + // not on the credit, or a wrong unit count, is rejected. + const overrideBySize = new Map( + (overrides ?? []).map((o) => [o.containerSize, o.units]), + ); + for (const size of overrideBySize.keys()) { + if (!(size in q.bySize)) { + throw new BadRequestException( + `The credit has no ${size} containers — sizes and quantities must match the cancelled booking.`, + ); + } + } const units = q.units ?? []; dto.containers = Object.entries(q.bySize).map(([size, quantity]) => { const sized = units.filter((u) => u.containerSize === size); @@ -1175,13 +1482,23 @@ export class BookingWagonCancellationService { `Credit is missing unit snapshots for size ${size} (${sized.length}/${quantity}) — contact EDR support.`, ); } + const replacement = overrideBySize.get(size); + if (replacement && replacement.length !== quantity) { + throw new BadRequestException( + `The credit covers exactly ${quantity} × ${size} — you entered ${replacement.length}. Quantities cannot change on a rebook.`, + ); + } return { containerSize: size, quantity, - units: sized.map((u) => ({ - containerNumber: u.containerNumber, - sealNumber: u.sealNumber ?? undefined, - vgmTons: u.vgmTons, + // Hazardous/reefer flags always ride from the snapshot (the cargo is + // the same cargo); number/seal/VGM come from the override when given. + units: sized.map((u, i) => ({ + containerNumber: replacement?.[i]?.containerNumber ?? u.containerNumber, + sealNumber: replacement + ? (replacement[i]?.sealNumber ?? undefined) + : (u.sealNumber ?? undefined), + vgmTons: replacement?.[i]?.vgmTons ?? u.vgmTons, isHazardous: u.isHazardous, isReefer: u.isReefer, })), diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 7cc3d60d8..03f13d186 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -17,57 +17,59 @@ import { UploadedFile, UploadedFiles, UseInterceptors, -} from '@nestjs/common'; -import { CurrentUser } from '@edr/api-common'; -import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +} from "@nestjs/common"; +import { CurrentUser } from "@edr/api-common"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; import { BookingStaff, BookingView, MixedAudience, PortalCustomer, WagonCancellationView, -} from '../../common/booking-guards'; -import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; -import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express'; +} from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { AnyFilesInterceptor, FileInterceptor } from "@nestjs/platform-express"; import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOkResponse, ApiOperation, + ApiQuery, ApiTags, } from "@nestjs/swagger"; import type { Response } from "express"; -import { BookingClearanceChargeService } from './booking-clearance-charge.service'; -import { BookingPayablesService } from './booking-payables.service'; -import { ClearanceEventService } from './clearance-event.service'; +import { BookingClearanceChargeService } from "./booking-clearance-charge.service"; +import { BookingPayablesService } from "./booking-payables.service"; +import { ClearanceEventService } from "./clearance-event.service"; +import { RejectClearanceChargeDto } from "./dto/clearance-charge.dto"; +import { BillClearanceChargeDto } from "./dto/clearance-charge.dto"; +import { AdditionalChargeService } from "./additional-charge.service"; import { - - RejectClearanceChargeDto, -} from './dto/clearance-charge.dto'; -import { BillClearanceChargeDto } from './dto/clearance-charge.dto'; -import { AdditionalChargeService } from './additional-charge.service'; -import { CancelAdditionalChargeDto, CreateAdditionalChargeDto } from './dto/additional-charge.dto'; -import { BookingContractService } from './booking-contract.service'; -import { BookingPricingService } from './booking-pricing.service'; -import { BookingTransitionService } from './booking-transition.service'; -import { BookingClearanceService } from '../contracts/booking-clearance.service'; + CancelAdditionalChargeDto, + CreateAdditionalChargeDto, +} from "./dto/additional-charge.dto"; +import { BookingContractService } from "./booking-contract.service"; +import { BookingPricingService } from "./booking-pricing.service"; +import { BookingTransitionService } from "./booking-transition.service"; +import { BookingClearanceService } from "../contracts/booking-clearance.service"; import { AdviseContractDutyDto, RoAmendmentDto, -} from '../contracts/dto/phased-clearance.dto'; -import { BookingReferenceDataService } from './booking-reference-data.service'; -import { scopedDirections } from '../user-trade-access/trade-scope.util'; -import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service'; -import { BookingsService } from './bookings.service'; -import { ConsolidationApprovalService } from './consolidation-approval.service'; -import { BookingReferenceDataDto } from './dto/booking-reference-data.dto'; -import { CreateBookingDto } from './dto/create-booking.dto'; -import { BookingListSummaryDto } from './dto/booking-list-summary.dto'; -import { FilterBookingDto } from './dto/filter-booking.dto'; -import { GeneratePriceResponseDto } from './dto/generate-price-response.dto'; -import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; +} from "../contracts/dto/phased-clearance.dto"; +import { BookingReferenceDataService } from "./booking-reference-data.service"; +import { scopedDirections } from "../user-trade-access/trade-scope.util"; +import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service"; +import { BookingsService } from "./bookings.service"; +import { ConsolidationApprovalService } from "./consolidation-approval.service"; +import { ConsolidationApprovalStatus } from "./entities/consolidation-approval.entity"; +import { BookingReferenceDataDto } from "./dto/booking-reference-data.dto"; +import { CreateBookingDto } from "./dto/create-booking.dto"; +import { BookingListSummaryDto } from "./dto/booking-list-summary.dto"; +import { FilterBookingDto } from "./dto/filter-booking.dto"; +import { GeneratePriceResponseDto } from "./dto/generate-price-response.dto"; +import { SubmitBookingResponseDto } from "./dto/submit-booking-response.dto"; import { AcceptIntakeDto, ApproveConsolidationDto, @@ -80,26 +82,26 @@ import { RequestOperationDto, OperationReviewDto, StaffRejectDto, -} from './dto/request-changes.dto'; -import { ContractViewDto } from './dto/contract-view.dto'; -import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'; -import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; -import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; -import { LoadCustomerTruckDto } from './dto/load-customer-truck.dto'; -import { CustomerTruckService } from './customer-truck.service'; -import { FirstMileService } from '../first-mile/first-mile.service'; -import { LastMileService } from '../last-mile/last-mile.service'; -import { GenerateGrnDto } from './dto/generate-grn.dto'; -import { ContainerReceiptService } from './container-receipt.service'; -import { SignContractDto } from './dto/sign-contract.dto'; -import { SetExportHandoverModeDto } from './dto/set-export-handover-mode.dto'; -import { UpdateBookingDto } from './dto/update-booking.dto'; -import { BookingWagonCancellationService } from './booking-wagon-cancellation.service'; +} from "./dto/request-changes.dto"; +import { ContractViewDto } from "./dto/contract-view.dto"; +import { CustomerTruckAssignmentDto } from "./dto/customer-truck-assignment.dto"; +import { AddCustomerTruckDto } from "./dto/add-customer-truck.dto"; +import { DepartCustomerTruckDto } from "./dto/depart-customer-truck.dto"; +import { LoadCustomerTruckDto } from "./dto/load-customer-truck.dto"; +import { CustomerTruckService } from "./customer-truck.service"; +import { FirstMileService } from "../first-mile/first-mile.service"; +import { LastMileService } from "../last-mile/last-mile.service"; +import { GenerateGrnDto } from "./dto/generate-grn.dto"; +import { ContainerReceiptService } from "./container-receipt.service"; +import { SignContractDto } from "./dto/sign-contract.dto"; +import { SetExportHandoverModeDto } from "./dto/set-export-handover-mode.dto"; +import { UpdateBookingDto } from "./dto/update-booking.dto"; +import { BookingWagonCancellationService } from "./booking-wagon-cancellation.service"; import { FilterWagonCancellationsDto, RebookCancelledWagonsDto, RequestWagonCancellationDto, -} from './dto/wagon-cancellation.dto'; +} from "./dto/wagon-cancellation.dto"; import { type AuthUserPayload, resolveAuthUserId, @@ -136,7 +138,7 @@ function summarizeMileLeg(rec?: Record): MileLegSummary | null { rec.vehicle?.currency ?? assignments[0]?.vehicle?.currency ?? rec.booking?.paymentCurrency ?? - 'ETB'; + "ETB"; const vehicles: MileVehicleSummary[] = assignments.map((a) => ({ plate: a.vehicle?.plateNumber ?? null, code: a.vehicle?.code ?? null, @@ -154,7 +156,7 @@ function summarizeMileLeg(rec?: Record): MileLegSummary | null { }); } return { - status: rec.status ?? '', + status: rec.status ?? "", exactKm: num(rec.exactKm), remainingPayment: num(rec.remainingPayment), currency, @@ -415,14 +417,14 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Get(':id/available-days') + @Get(":id/available-days") @MixedAudience([]) @ApiOperation({ summary: - 'Days bookable for THIS booking (cargo-aware wagon-TYPE gate; days only, no capacity counts)', + "Days bookable for THIS booking (cargo-aware wagon-TYPE gate; days only, no capacity counts)", }) async availableDays( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); @@ -438,17 +440,17 @@ export class BookingsController { return this.bookingsService.availableDaysForBooking(id); } - @Get(':id/day-availability') + @Get(":id/day-availability") @MixedAudience([]) @ApiOperation({ summary: - 'Advisory free-wagon count for a shipment day (planning hint, not enforced). ' + - 'Export: whole-booking fit + largest single-train leftover. ' + - 'Import/domestic: total room across the day for the booking\'s wagon type.', + "Advisory free-wagon count for a shipment day (planning hint, not enforced). " + + "Export: whole-booking fit + largest single-train leftover. " + + "Import/domestic: total room across the day for the booking's wagon type.", }) async dayAvailability( - @Param('id', ParseUUIDPipe) id: string, - @Query('date') date: string, + @Param("id", ParseUUIDPipe) id: string, + @Query("date") date: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); @@ -464,13 +466,14 @@ export class BookingsController { return this.transitionService.dayAvailabilityForBooking(id, date); } - @Get(':id/mile-summary') + @Get(":id/mile-summary") @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOperation({ - summary: 'First/last-mile operational summary for a booking (customer-safe)', + summary: + "First/last-mile operational summary for a booking (customer-safe)", }) async mileSummary( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { // Customers may only see their own booking's mile summary. @@ -479,7 +482,10 @@ export class BookingsController { !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) ) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } const [first, last] = await Promise.all([ @@ -492,82 +498,100 @@ export class BookingsController { }; } - @Post(':id/customer-truck-assignment') + @Post(":id/customer-truck-assignment") @PortalCustomer() - @ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' }) + @ApiOperation({ + summary: "Customer assigns external truck and driver for terminal pickup", + }) async assignCustomerTruck( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: CustomerTruckAssignmentDto, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } const assigned = await this.bookingsService.assignCustomerTruck(id, dto); return this.transitionService.enrichBookingResponse(assigned); } - @Get(':id/customer-truck-assignment/freight-order') + @Get(":id/customer-truck-assignment/freight-order") @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOperation({ summary: - 'Download freight order copies. The 2 gate copies always print; ?copies=1,2,8 adds waybill-style copies (catalog indexes 1-8).', + "Download freight order copies. The 2 gate copies always print; ?copies=1,2,8 adds waybill-style copies (catalog indexes 1-8).", }) async customerTruckFreightOrder( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, @Res() res: Response, - @Query('copies') copies?: string, + @Query("copies") copies?: string, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } - const extraCopyIndexes = (copies ?? '') - .split(',') + const extraCopyIndexes = (copies ?? "") + .split(",") .map((n) => Number(n.trim())) .filter((n) => Number.isInteger(n) && n >= 1 && n <= 8); const { filename, buffer } = - await this.bookingsService.customerTruckFreightOrderCopies(id, extraCopyIndexes); - res.setHeader('Content-Type', 'application/pdf'); - res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + await this.bookingsService.customerTruckFreightOrderCopies( + id, + extraCopyIndexes, + ); + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); res.send(buffer); } - @Get(':id/carriage-acceptance-sheet') + @Get(":id/carriage-acceptance-sheet") @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOperation({ summary: - 'Download the carriage acceptance sheet (one per booking, lists every allocated wagon)', + "Download the carriage acceptance sheet (one per booking, lists every allocated wagon)", }) async carriageAcceptanceSheet( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, @Res() res: Response, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } - const { filename, buffer } = await this.bookingsService.carriageAcceptanceSheet(id); - res.setHeader('Content-Type', 'application/pdf'); - res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + const { filename, buffer } = + await this.bookingsService.carriageAcceptanceSheet(id); + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); res.send(buffer); } - @Get(':id/wagons') + @Get(":id/wagons") @ApiOperation({ summary: - 'Allocated wagons for a booking (JSON) — empty until the paid booking is placed on a train', + "Allocated wagons for a booking (JSON) — empty until the paid booking is placed on a train", }) async wagonAllocations( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } return this.bookingsService.wagonAllocations(id); } @@ -576,41 +600,52 @@ export class BookingsController { // Customer endpoints are ownership-scoped (no portal permission keys); the // staff history/void/rebook variants are permission-gated below. - @Post(':id/wagon-cancellations/preview') - @ApiOperation({ summary: 'Preview the fee/credit of a partial wagon cancellation (no writes)' }) + @Post(":id/wagon-cancellations/preview") + @ApiOperation({ + summary: + "Preview the fee/credit of a partial wagon cancellation (no writes)", + }) async previewWagonCancellation( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: RequestWagonCancellationDto, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } return this.wagonCancellationService.previewCancellation(id, dto); } - @Post(':id/wagon-cancellations') + @Post(":id/wagon-cancellations") @ApiOperation({ summary: - 'Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles', + "Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles", }) async requestWagonCancellation( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: RequestWagonCancellationDto, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } return this.wagonCancellationService.requestCancellation(id, dto, user?.id); } - @Get(':id/wagon-cancellations') - @ApiOperation({ summary: 'Wagon-cancellation history of one booking (owner or staff)' }) + @Get(":id/wagon-cancellations") + @ApiOperation({ + summary: "Wagon-cancellation history of one booking (owner or staff)", + }) async listBookingWagonCancellations( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); @@ -618,19 +653,28 @@ export class BookingsController { hasFreightPermission(user, FREIGHT_PERMS.bookings.view) || hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView); if (!staff) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } return this.wagonCancellationService.list({ bookingId: id, pageSize: 100 }); } - @Get('wagon-cancellations/my') - @ApiOperation({ summary: 'Wagon-cancellation history of the calling customer (paginated, filterable)' }) + @Get("wagon-cancellations/my") + @ApiOperation({ + summary: + "Wagon-cancellation history of the calling customer (paginated, filterable)", + }) async listMyWagonCancellations( @Query() filter: FilterWagonCancellationsDto, @CurrentUser() user: TCurrentUser, ) { - const companyId = await this.bookingsService.resolveCustomerCompanyId(user?.id ?? ''); - if (!companyId) throw new ForbiddenException('No customer company for this user.'); + const companyId = await this.bookingsService.resolveCustomerCompanyId( + user?.id ?? "", + ); + if (!companyId) + throw new ForbiddenException("No customer company for this user."); return this.wagonCancellationService.list({ companyId, status: filter.statuses, @@ -642,10 +686,14 @@ export class BookingsController { }); } - @Get('wagon-cancellations/history') + @Get("wagon-cancellations/history") @WagonCancellationView() - @ApiOperation({ summary: 'All wagon cancellations (staff, paginated, filterable)' }) - async listAllWagonCancellations(@Query() filter: FilterWagonCancellationsDto) { + @ApiOperation({ + summary: "All wagon cancellations (staff, paginated, filterable)", + }) + async listAllWagonCancellations( + @Query() filter: FilterWagonCancellationsDto, + ) { return this.wagonCancellationService.list({ status: filter.statuses, search: filter.search, @@ -656,27 +704,34 @@ export class BookingsController { }); } - @Post('wagon-cancellations/:cancellationId/withdraw') - @ApiOperation({ summary: 'Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)' }) + @Post("wagon-cancellations/:cancellationId/withdraw") + @ApiOperation({ + summary: + "Withdraw a fee-pending wagon cancellation — STAFF ONLY (void permission). A customer cancellation is final; only an admin can revert it.", + }) async withdrawWagonCancellation( - @Param('cancellationId', ParseUUIDPipe) cancellationId: string, + @Param("cancellationId", ParseUUIDPipe) cancellationId: string, @CurrentUser() user: TCurrentUser, ) { - await this.assertWagonCancellationActor( - cancellationId, - user, - FREIGHT_PERMS.bookings.wagonCancellationVoid, - ); + // Customer cancellations are irreversible from the portal — no owner + // fallback here. Only staff holding the void permission can revert one. + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationVoid) + ) { + throw new ForbiddenException( + "A cancellation request cannot be withdrawn from the portal — contact EDR staff.", + ); + } return this.wagonCancellationService.withdraw(cancellationId); } - @Post('wagon-cancellations/:cancellationId/rebook') + @Post("wagon-cancellations/:cancellationId/rebook") @ApiOperation({ summary: - 'Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)', + "Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)", }) async rebookWagonCancellation( - @Param('cancellationId', ParseUUIDPipe) cancellationId: string, + @Param("cancellationId", ParseUUIDPipe) cancellationId: string, @Body() dto: RebookCancelledWagonsDto, @CurrentUser() user: TCurrentUser, ) { @@ -697,180 +752,228 @@ export class BookingsController { if (hasFreightPermission(user, staffPermission)) return; const row = await this.wagonCancellationService.findById(cancellationId); const booking = await this.bookingsService.findById(row.bookingId); - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } - @Get(':id/customer-trucks') + @Get(":id/customer-trucks") @MixedAudience([ FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.bookings.operations, FREIGHT_PERMS.warehouseInventory.view, ]) - @ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' }) + @ApiOperation({ + summary: "List customer self-haul trucks (multi-truck) for a booking", + }) async listCustomerTrucks( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } return this.customerTruckService.listTrucks(id); } - @Post(':id/customer-trucks') + @Post(":id/customer-trucks") @PortalCustomer() - @ApiOperation({ summary: 'Add a customer self-haul truck carrying 1–2 of the booking containers' }) + @ApiOperation({ + summary: + "Add a customer self-haul truck carrying 1–2 of the booking containers", + }) async addCustomerTruck( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: AddCustomerTruckDto, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } return this.customerTruckService.addTruck(id, dto); } - @Post(':id/customer-trucks/bulk') + @Post(":id/customer-trucks/bulk") @PortalCustomer() - @ApiOperation({ summary: 'Bulk add customer trucks from array payload (Excel parsed)' }) + @ApiOperation({ + summary: "Bulk add customer trucks from array payload (Excel parsed)", + }) async bulkAddCustomerTrucks( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() payload: { trucks: AddCustomerTruckDto[] }, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } return this.customerTruckService.addBulkTrucks(id, payload.trucks); } - @Patch(':id/customer-trucks/:assignmentId') + @Patch(":id/customer-trucks/:assignmentId") @PortalCustomer() - @ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' }) + @ApiOperation({ + summary: + "Edit a not-yet-arrived customer truck (plate/driver/type + containers)", + }) async updateCustomerTruck( - @Param('id', ParseUUIDPipe) id: string, - @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Param("id", ParseUUIDPipe) id: string, + @Param("assignmentId", ParseUUIDPipe) assignmentId: string, @Body() dto: AddCustomerTruckDto, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } return this.customerTruckService.updateTruck(id, assignmentId, dto); } - @Delete(':id/customer-trucks/:assignmentId') + @Delete(":id/customer-trucks/:assignmentId") @PortalCustomer() - @ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' }) + @ApiOperation({ + summary: "Remove a not-yet-arrived customer truck from a booking", + }) async removeCustomerTruck( - @Param('id', ParseUUIDPipe) id: string, - @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Param("id", ParseUUIDPipe) id: string, + @Param("assignmentId", ParseUUIDPipe) assignmentId: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } return this.customerTruckService.removeTruck(id, assignmentId); } - @Get(':id/customer-trucks/loadable-containers') + @Get(":id/customer-trucks/loadable-containers") @MixedAudience(FREIGHT_PERMS.bookings.view) - @ApiOperation({ summary: 'Booking containers not yet loaded onto a truck' }) + @ApiOperation({ summary: "Booking containers not yet loaded onto a truck" }) async loadableContainers( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } return this.customerTruckService.getLoadableContainers(id); } - @Patch(':id/export-handover-mode') + @Patch(":id/export-handover-mode") @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ - summary: 'Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first', + summary: + "Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first", }) setExportHandoverMode( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: SetExportHandoverModeDto, ) { - return this.bookingsService.setExportHandoverMode(id, dto.exportHandoverMode); + return this.bookingsService.setExportHandoverMode( + id, + dto.exportHandoverMode, + ); } - @Post(':id/customer-trucks/:assignmentId/load') + @Post(":id/customer-trucks/:assignmentId/load") @BookingStaff(FREIGHT_PERMS.bookings.operations) - @ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' }) + @ApiOperation({ + summary: "Truck_dispatch: load selected containers onto a truck (staff)", + }) async loadCustomerTruck( - @Param('id', ParseUUIDPipe) id: string, - @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Param("id", ParseUUIDPipe) id: string, + @Param("assignmentId", ParseUUIDPipe) assignmentId: string, @Body() dto: LoadCustomerTruckDto, @CurrentUser() user: TCurrentUser, ) { if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - throw new ForbiddenException('Only warehouse staff can load a truck'); + throw new ForbiddenException("Only warehouse staff can load a truck"); } return this.customerTruckService.loadTruck(id, assignmentId, dto); } - @Post(':id/customer-trucks/:assignmentId/depart') + @Post(":id/customer-trucks/:assignmentId/depart") @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ - summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)', + summary: + "Register an import truck leaving: containers loaded + weighed gross (staff)", }) async departCustomerTruck( - @Param('id', ParseUUIDPipe) id: string, - @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Param("id", ParseUUIDPipe) id: string, + @Param("assignmentId", ParseUUIDPipe) assignmentId: string, @Body() dto: DepartCustomerTruckDto, @CurrentUser() user: TCurrentUser, ) { // Weighing + registering the load on exit is a warehouse/gate staff action. if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - throw new ForbiddenException('Only warehouse staff can register a truck departure'); + throw new ForbiddenException( + "Only warehouse staff can register a truck departure", + ); } return this.customerTruckService.departTruck(id, assignmentId, dto); } - @Get(':id/received-pending-grn') + @Get(":id/received-pending-grn") @MixedAudience(FREIGHT_PERMS.bookings.view) - @ApiOperation({ summary: 'Containers received into port but not yet on a GRN' }) + @ApiOperation({ + summary: "Containers received into port but not yet on a GRN", + }) async receivedPendingGrn( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { // GRN is a warehouse-staff action — no customer access. if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - throw new ForbiddenException('Only warehouse staff can view or generate GRNs'); + throw new ForbiddenException( + "Only warehouse staff can view or generate GRNs", + ); } return this.containerReceiptService.listReceivedPendingGrn(id); } - @Post(':id/generate-grn') + @Post(":id/generate-grn") @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: - 'Generate a GRN over the received containers (all received, or a subset) — one GRN per batch', + "Generate a GRN over the received containers (all received, or a subset) — one GRN per batch", }) async generateGrn( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: GenerateGrnDto, @CurrentUser() user: TCurrentUser, ) { // GRN is a warehouse-staff action — no customer access. if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - throw new ForbiddenException('Only warehouse staff can view or generate GRNs'); + throw new ForbiddenException( + "Only warehouse staff can view or generate GRNs", + ); } return this.containerReceiptService.generateGrn(id, dto.containerNumbers); } - @Get(':id/tracking') + @Get(":id/tracking") @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOperation({ summary: "Shipment tracking timeline for a booking", @@ -967,22 +1070,29 @@ export class BookingsController { // ── Document clearance (post counter-sign) ──────────────────────────────── - @Get('clearance/et-queue') + @Get("clearance/et-queue") @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) - @ApiOperation({ summary: 'GL ET queue — general customs bookings awaiting ET action' }) + @ApiOperation({ + summary: "GL ET queue — general customs bookings awaiting ET action", + }) getBookingEtClearanceQueue(@CurrentUser() user: unknown) { return this.bookingClearanceService.etQueue(user); } - @Get('clearance/dj-queue') + @Get("clearance/dj-queue") @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @ApiOperation({ summary: 'GL DJ queue — general customs bookings awaiting DJ action' }) + @ApiOperation({ + summary: "GL DJ queue — general customs bookings awaiting DJ action", + }) getBookingDjClearanceQueue() { return this.bookingClearanceService.djQueue(); } - @Get(':id/clearance') - @MixedAudience([FREIGHT_PERMS.bookings.clearanceView, FREIGHT_PERMS.bookings.reviewDocuments]) + @Get(":id/clearance") + @MixedAudience([ + FREIGHT_PERMS.bookings.clearanceView, + FREIGHT_PERMS.bookings.reviewDocuments, + ]) @ApiOperation({ summary: "Document-clearance grid (required docs + upload + GL review status)", @@ -1062,7 +1172,10 @@ export class BookingsController { : undefined, cargoTypeId: cargoTypeId || undefined, cargoTypeCode: cargoTypeCode || undefined, - wagons: Number.isFinite(parsedWagons) && parsedWagons > 0 ? parsedWagons : undefined, + wagons: + Number.isFinite(parsedWagons) && parsedWagons > 0 + ? parsedWagons + : undefined, }); } @@ -1159,7 +1272,10 @@ export class BookingsController { hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions); if (isStaff) return this.clearanceChargeService.list(id); const booking = await this.bookingsService.findById(id); - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); return this.clearanceChargeService.listForCustomer(id); } @@ -1206,7 +1322,8 @@ export class BookingsController { @UseInterceptors(FileInterceptor("file")) @ApiConsumes("multipart/form-data") @ApiOperation({ - summary: "GL Djibouti uploads (or replaces, until billed) the port-charges document", + summary: + "GL Djibouti uploads (or replaces, until billed) the port-charges document", }) uploadPortChargeDocument( @Param("id", ParseUUIDPipe) id: string, @@ -1286,15 +1403,23 @@ export class BookingsController { @Get(":id/additional-charges") @MixedAudience(FREIGHT_PERMS.additionalCharges.view) - @ApiOperation({ summary: "Ad-hoc extra charges finance has raised against this booking" }) + @ApiOperation({ + summary: "Ad-hoc extra charges finance has raised against this booking", + }) async getAdditionalCharges( @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { - const isStaff = hasFreightPermission(user, FREIGHT_PERMS.additionalCharges.view); + const isStaff = hasFreightPermission( + user, + FREIGHT_PERMS.additionalCharges.view, + ); if (!isStaff) { const booking = await this.bookingsService.findById(id); - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } const charges = await this.additionalChargeService.list(id); // A charge finance hasn't sent yet isn't the customer's to see. @@ -1306,7 +1431,8 @@ export class BookingsController { @UseInterceptors(FileInterceptor("file")) @ApiConsumes("multipart/form-data") @ApiOperation({ - summary: "Finance raises a new additional charge — draft, or send to the customer immediately", + summary: + "Finance raises a new additional charge — draft, or send to the customer immediately", }) createAdditionalCharge( @Param("id", ParseUUIDPipe) id: string, @@ -1314,18 +1440,29 @@ export class BookingsController { @Body() dto: CreateAdditionalChargeDto, @CurrentUser() user: TCurrentUser, ) { - return this.additionalChargeService.create(id, dto, resolveAuthUserId(user), file); + return this.additionalChargeService.create( + id, + dto, + resolveAuthUserId(user), + file, + ); } @Post(":id/additional-charges/:chargeId/send") @BookingStaff(FREIGHT_PERMS.additionalCharges.send) - @ApiOperation({ summary: "Issue the draft charge's payable invoice and notify the customer" }) + @ApiOperation({ + summary: "Issue the draft charge's payable invoice and notify the customer", + }) sendAdditionalCharge( @Param("id", ParseUUIDPipe) id: string, @Param("chargeId", ParseUUIDPipe) chargeId: string, @CurrentUser() user: TCurrentUser, ) { - return this.additionalChargeService.send(id, chargeId, resolveAuthUserId(user)); + return this.additionalChargeService.send( + id, + chargeId, + resolveAuthUserId(user), + ); } @Post(":id/additional-charges/:chargeId/cancel") @@ -1337,7 +1474,12 @@ export class BookingsController { @Body() dto: CancelAdditionalChargeDto, @CurrentUser() user: TCurrentUser, ) { - return this.additionalChargeService.cancel(id, chargeId, resolveAuthUserId(user), dto.reason); + return this.additionalChargeService.cancel( + id, + chargeId, + resolveAuthUserId(user), + dto.reason, + ); } @Post(":id/clearance/output-documents") @@ -1375,15 +1517,15 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/transit-assignee/request') + @Post(":id/clearance/transit-assignee/request") @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @ApiOperation({ summary: - 'GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration', + "GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration", }) async requestBookingTransitAssignee( - @Param('id', ParseUUIDPipe) id: string, - @Body('note') note: string | undefined, + @Param("id", ParseUUIDPipe) id: string, + @Body("note") note: string | undefined, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.bookingClearanceService.requestTransitAssignee( @@ -1394,15 +1536,15 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/transit-assignee/assign') + @Post(":id/clearance/transit-assignee/assign") @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @ApiOperation({ summary: - 'GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns', + "GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", }) async assignBookingTransitAssignee( - @Param('id', ParseUUIDPipe) id: string, - @Body('transitAgentId', ParseUUIDPipe) transitAgentId: string, + @Param("id", ParseUUIDPipe) id: string, + @Body("transitAgentId", ParseUUIDPipe) transitAgentId: string, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.bookingClearanceService.assignTransitAssignee( @@ -1413,13 +1555,15 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/declaration') + @Post(":id/clearance/declaration") @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'GL ET uploads customs declaration on booking (GENERAL customs)' }) + @ApiConsumes("multipart/form-data") + @ApiOperation({ + summary: "GL ET uploads customs declaration on booking (GENERAL customs)", + }) async uploadBookingDeclaration( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], @CurrentUser() user: TCurrentUser, ) { @@ -1431,26 +1575,28 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/duty') + @Post(":id/clearance/duty") @BookingStaff(FREIGHT_PERMS.contracts.clearanceDutyAdvise) - @UseInterceptors(FileInterceptor('attachment')) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'GL ET sets duty/tax on booking with notice attachment' }) + @UseInterceptors(FileInterceptor("attachment")) + @ApiConsumes("multipart/form-data") + @ApiOperation({ + summary: "GL ET sets duty/tax on booking with notice attachment", + }) async adviseBookingDuty( - @Param('id', ParseUUIDPipe) id: string, - @Body('dutyRequired') dutyRequiredRaw: string, - @Body('amount') amountRaw: string | undefined, - @Body('currency') currency: string | undefined, - @Body('declarationSerial') declarationSerial: string | undefined, + @Param("id", ParseUUIDPipe) id: string, + @Body("dutyRequired") dutyRequiredRaw: string, + @Body("amount") amountRaw: string | undefined, + @Body("currency") currency: string | undefined, + @Body("declarationSerial") declarationSerial: string | undefined, @UploadedFile() attachment: Express.Multer.File | undefined, @CurrentUser() user: TCurrentUser, ) { - const dutyRequired = dutyRequiredRaw === 'true' || dutyRequiredRaw === '1'; + const dutyRequired = dutyRequiredRaw === "true" || dutyRequiredRaw === "1"; const dto: AdviseContractDutyDto = { dutyRequired, amount: - amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined, - currency: currency ?? 'ETB', + amountRaw != null && amountRaw !== "" ? Number(amountRaw) : undefined, + currency: currency ?? "ETB", declarationSerial, }; const booking = await this.bookingClearanceService.adviseDuty( @@ -1462,18 +1608,18 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/draft-declaration') + @Post(":id/clearance/draft-declaration") @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') + @ApiConsumes("multipart/form-data") @ApiOperation({ summary: - 'GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review', + "GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review", }) async uploadBookingDraftDeclaration( - @Param('id', ParseUUIDPipe) id: string, - @Body('price') priceRaw: string, - @Body('currency') currency: string | undefined, + @Param("id", ParseUUIDPipe) id: string, + @Body("price") priceRaw: string, + @Body("currency") currency: string | undefined, @UploadedFiles() files: Express.Multer.File[], @CurrentUser() user: TCurrentUser, ) { @@ -1481,20 +1627,20 @@ export class BookingsController { id, files ?? [], Number(priceRaw), - currency ?? 'ETB', + currency ?? "ETB", resolveAuthUserId(user), ); return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/draft-declaration/accept') + @Post(":id/clearance/draft-declaration/accept") @PortalCustomer() @ApiOperation({ summary: - 'Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia', + "Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia", }) async acceptBookingDraftDeclaration( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.bookingClearanceService.acceptDraftDeclaration( @@ -1504,30 +1650,31 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/draft-declaration/change') + @Post(":id/clearance/draft-declaration/change") @PortalCustomer() @ApiOperation({ summary: - 'Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)', + "Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)", }) async requestBookingDraftDeclarationChange( - @Param('id', ParseUUIDPipe) id: string, - @Body('note') note: string, + @Param("id", ParseUUIDPipe) id: string, + @Body("note") note: string, @CurrentUser() user: TCurrentUser, ) { - const booking = await this.bookingClearanceService.requestDraftDeclarationChange( - id, - note, - resolveAuthUserId(user), - ); + const booking = + await this.bookingClearanceService.requestDraftDeclarationChange( + id, + note, + resolveAuthUserId(user), + ); return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/finalize-pre-clearance') + @Post(":id/clearance/finalize-pre-clearance") @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) - @ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' }) + @ApiOperation({ summary: "GL ET finalizes import pre-clearance on booking" }) async finalizeBookingPreClearance( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.bookingClearanceService.finalizePreClearance( @@ -1537,13 +1684,15 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/duty-slip') + @Post(":id/clearance/duty-slip") @PortalCustomer() - @UseInterceptors(FileInterceptor('file')) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'Customer uploads duty/tax payment slip on booking' }) + @UseInterceptors(FileInterceptor("file")) + @ApiConsumes("multipart/form-data") + @ApiOperation({ + summary: "Customer uploads duty/tax payment slip on booking", + }) async uploadBookingDutySlip( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File, @CurrentUser() user: AuthUserPayload, ) { @@ -1555,12 +1704,12 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/transit-permit') + @Post(":id/clearance/transit-permit") @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') + @ApiConsumes("multipart/form-data") async uploadBookingTransitPermit( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], @CurrentUser() user: TCurrentUser, ) { @@ -1572,15 +1721,15 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/delivery-order') + @Post(":id/clearance/delivery-order") @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') + @ApiConsumes("multipart/form-data") async uploadBookingDeliveryOrder( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], - @Body('vesselArrivalDate') vesselArrivalDate: string | undefined, - @Body('doCollectedDate') doCollectedDate: string | undefined, + @Body("vesselArrivalDate") vesselArrivalDate: string | undefined, + @Body("doCollectedDate") doCollectedDate: string | undefined, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingClearanceService.uploadDeliveryOrder( @@ -1592,14 +1741,14 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/release-order') + @Post(":id/clearance/release-order") @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') + @ApiConsumes("multipart/form-data") async uploadBookingReleaseOrder( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], - @Body('vesselDepartureDate') vesselDepartureDate: string, + @Body("vesselDepartureDate") vesselDepartureDate: string, @CurrentUser() user: TCurrentUser, ) { const result = await this.bookingClearanceService.uploadReleaseOrder( @@ -1615,10 +1764,10 @@ export class BookingsController { }; } - @Post(':id/clearance/ro-amendment') + @Post(":id/clearance/ro-amendment") @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) async requestBookingRoAmendment( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: RoAmendmentDto, @CurrentUser() user: TCurrentUser, ) { @@ -1630,10 +1779,10 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/export-release') + @Post(":id/clearance/export-release") @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) async confirmBookingExportRelease( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingClearanceService.confirmExportRelease( @@ -1643,7 +1792,7 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/staff/request-changes') + @Post(":id/staff/request-changes") @BookingStaff(FREIGHT_PERMS.bookings.requestChanges) @ApiOperation({ summary: "Staff return booking for customer updates" }) async requestChanges( @@ -1855,12 +2004,31 @@ export class BookingsController { @Get("consolidation-approvals/queue") @BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation) + @ApiQuery({ + name: "status", + required: false, + enum: ConsolidationApprovalStatus, + description: "Filter to one status. Omit for pending first, then decided.", + }) + @ApiQuery({ name: "page", required: false, type: Number }) + @ApiQuery({ name: "pageSize", required: false, type: Number }) @ApiOperation({ summary: - "Shared-wagon pairings awaiting approval, oldest first. Each row covers BOTH bookings on the wagon.", + "One page of shared-wagon pairings: pending ones first (oldest first), then the decided history with who decided each. Every row covers BOTH bookings on the wagon.", }) - consolidationApprovalQueue() { - return this.consolidationApprovalService.queue(); + consolidationApprovalQueue( + @CurrentUser() user: AuthUserPayload, + @Query("status") status?: ConsolidationApprovalStatus, + @Query("page") page?: string, + @Query("pageSize") pageSize?: string, + ) { + return this.consolidationApprovalService.queue({ + status, + page: page ? Number(page) : undefined, + pageSize: pageSize ? Number(pageSize) : undefined, + // Narrows to the yards the caller's desk is mapped to. + user, + }); } @Get(":id/consolidation-approvals") @@ -1888,6 +2056,7 @@ export class BookingsController { approvalId, resolveAuthUserId(user) ?? "", dto.note, + user, ); } @@ -1906,6 +2075,7 @@ export class BookingsController { approvalId, resolveAuthUserId(user) ?? "", dto.reason, + user, ); } @@ -1920,12 +2090,13 @@ export class BookingsController { @Body() dto: PairedDecisionDto, @CurrentUser() user: AuthUserPayload, ) { - const { booking, partner } = await this.transitionService.applyPairedDecision( - id, - dto.decision, - resolveAuthUserId(user), - { reason: dto.reason, note: dto.note, validityDays: dto.validityDays }, - ); + const { booking, partner } = + await this.transitionService.applyPairedDecision( + id, + dto.decision, + resolveAuthUserId(user), + { reason: dto.reason, note: dto.note, validityDays: dto.validityDays }, + ); // Sequential enrichment: both go back so the UI can refresh either tab. const enrichedBooking = await this.transitionService.enrichBookingResponse(booking); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index efd6ed217..ff0888bef 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -596,6 +596,21 @@ export class BookingsRepository extends BaseRepository { } 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 { + await this.repository.update(bookingId, { + consolidationPartnerId: null, + } as never); + await this.repository.update(partnerId, { + consolidationPartnerId: null, + } as never); + } + /** Un-pair a consolidation. */ async unpairConsolidation(bookingId: string, partnerId: string): Promise { await this.repository.update(bookingId, { diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 0b5bf5a0a..b6047633a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -52,6 +52,7 @@ import { FreightType, } from './entities/booking.entity'; import { Booking } from './entities/booking.entity'; +import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity'; import { BookingContainerAllocation } from './entities/booking-container-allocation.entity'; import { FileRecord } from '../files/entities/file.entity'; import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'; @@ -427,7 +428,8 @@ export class BookingsService { 'containerNumber', ci.container_number, 'sealNumber', ci.seal_number, '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 ) 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.wagon_allocation_container_items ci 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 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 @@ -2260,16 +2263,25 @@ export class BookingsService { return this.findById(booking.id); } - /** Upload documents for a DRAFT booking. */ + /** + * Upload documents for a DRAFT booking — or for a booking created by + * rebooking a wagon-cancellation credit, whose paperwork may have changed + * with the new containers (old documents stay; new ones ride alongside). + */ async uploadDocuments( id: string, files: Express.Multer.File[], ): Promise { const booking = await this.findById(id); if (booking.status !== 'DRAFT') { - throw new BadRequestException( - 'Documents can only be uploaded for DRAFT bookings', - ); + const rebooked = await this.dataSource + .getRepository(BookingWagonCancellation) + .findOne({ where: { rebookedBookingId: id } }); + if (!rebooked) { + throw new BadRequestException( + 'Documents can only be uploaded for DRAFT bookings', + ); + } } await this.filesService.uploadMany(id, 'bookings', files); return this.findById(id); diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.spec.ts index 5f121f630..e94ddd0b3 100644 --- a/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.spec.ts @@ -1,9 +1,9 @@ import { ConsolidationApprovalService, CONSOLIDATION_APPROVAL_PENDING, -} from './consolidation-approval.service'; -import { ConsolidationApprovalStatus } from './entities/consolidation-approval.entity'; -import { Booking } from './entities/booking.entity'; +} from "./consolidation-approval.service"; +import { ConsolidationApprovalStatus } from "./entities/consolidation-approval.entity"; +import { Booking } from "./entities/booking.entity"; /** * 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 * a decided pairing cannot be decided twice. */ -describe('ConsolidationApprovalService', () => { +describe("ConsolidationApprovalService", () => { const PENDING = { - id: 'ap-1', - bookingId: 'b-1', - partnerBookingId: 'b-2', + id: "ap-1", + bookingId: "b-1", + partnerBookingId: "b-2", status: ConsolidationApprovalStatus.Pending, - requestedBy: 'gl-user', + requestedBy: "gl-user", }; - function makeService(overrides: { - approvals?: Partial>; - bookingsRepository?: Partial>; - } = {}) { + function makeService( + overrides: { + approvals?: Partial>; + bookingsRepository?: Partial>; + bookingsService?: Partial>; + /** 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 = { findPendingForBooking: jest.fn().mockResolvedValue(null), findById: jest.fn().mockResolvedValue(PENDING), - create: jest.fn().mockResolvedValue({ id: 'ap-1' }), + create: jest.fn().mockResolvedValue({ id: "ap-1" }), 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([]), ...overrides.approvals, }; const bookingsRepository = { update: jest.fn().mockResolvedValue(undefined), createReviewNote: jest.fn().mockResolvedValue(undefined), + resolveStaffNames: jest.fn().mockResolvedValue(new Map()), ...overrides.bookingsRepository, }; const bookingsService = { - findById: jest.fn(async (id: string) => - ({ id, reference: `BK-${id}` }) as Booking, + findById: jest.fn( + async (id: string) => + ({ + id, + reference: `BK-${id}`, + originYardId: "mojo", + destinationYardId: "djibouti", + }) as Booking, ), + ...overrides.bookingsService, }; const notifier = { consolidationApprovalRequestedToStaff: jest.fn(), @@ -52,8 +70,19 @@ describe('ConsolidationApprovalService', () => { consolidationRejectedToStaff: jest.fn(), operationRequestedToStaff: jest.fn(), }; + const contractRepo = { + find: jest + .fn() + .mockResolvedValue(overrides.contracts ?? []), + }; const dataSource = { transaction: jest.fn(async (cb: () => Promise) => cb()), + getRepository: jest.fn(() => contractRepo), + }; + const yardScope = { + getScopedYardIds: jest + .fn() + .mockResolvedValue(overrides.yardScope ?? null), }; const service = new ConsolidationApprovalService( @@ -62,27 +91,35 @@ describe('ConsolidationApprovalService', () => { bookingsService as never, notifier 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(); - await service.requestApproval('b-1', 'b-2', 'gl-user'); + await service.requestApproval("b-1", "b-2", "gl-user"); expect(approvals.create).toHaveBeenCalledWith( expect.objectContaining({ - bookingId: 'b-1', - partnerBookingId: 'b-2', - requestedBy: 'gl-user', + bookingId: "b-1", + partnerBookingId: "b-2", + requestedBy: "gl-user", }), ); // 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, }); - expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', { + expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", { status: CONSOLIDATION_APPROVAL_PENDING, }); expect( @@ -90,94 +127,102 @@ describe('ConsolidationApprovalService', () => { ).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({ approvals: { 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(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(); - await service.approve('ap-1', 'approver-1', 'looks fine'); + await service.approve("ap-1", "approver-1", "looks fine"); expect(approvals.decide).toHaveBeenCalledWith( - 'ap-1', + "ap-1", ConsolidationApprovalStatus.Approved, - 'approver-1', - 'looks fine', + "approver-1", + "looks fine", + [ + ConsolidationApprovalStatus.Pending, + ConsolidationApprovalStatus.Rejected, + ], ); - expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', { - status: 'OPERATION_REQUEST_PENDING', + expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", { + status: "OPERATION_REQUEST_PENDING", }); - expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', { - status: 'OPERATION_REQUEST_PENDING', + expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", { + status: "OPERATION_REQUEST_PENDING", }); // Operations only learns about the pair now — the gate is what kept it out. 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(); - 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( - 'ap-1', + "ap-1", ConsolidationApprovalStatus.Rejected, - 'approver-1', - 'partner cargo is wrong', + "approver-1", + "partner cargo is wrong", ); expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith( - 'b-1', - 'partner cargo is wrong', - 'CHANGES_REQUESTED', + "b-1", + "partner cargo is wrong", + "CHANGES_REQUESTED", ); expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith( - 'b-2', - 'partner cargo is wrong', - 'CHANGES_REQUESTED', + "b-2", + "partner cargo is wrong", + "CHANGES_REQUESTED", ); - expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', { - status: 'OPERATION_CHANGES_REQUESTED', + expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", { + status: "OPERATION_CHANGES_REQUESTED", }); - expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', { - status: 'OPERATION_CHANGES_REQUESTED', + expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", { + 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, // and the audit trail still records requester and approver separately. const { service, approvals } = makeService(); - await service.approve('ap-1', 'gl-user'); + await service.approve("ap-1", "gl-user"); expect(approvals.decide).toHaveBeenCalledWith( - 'ap-1', + "ap-1", ConsolidationApprovalStatus.Approved, - 'gl-user', + "gl-user", undefined, + [ + ConsolidationApprovalStatus.Pending, + ConsolidationApprovalStatus.Rejected, + ], ); }); - it('requires a reason to reject', async () => { + it("requires a reason to reject", async () => { 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, ); 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({ approvals: { 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, ); 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 // affects nothing and must not move the bookings. const { service } = makeService({ 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, ); }); + + 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 (contract–booking 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(); + }); }); diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.ts b/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.ts index ab4f4891d..dcc499966 100644 --- a/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.ts @@ -1,13 +1,14 @@ import { BadRequestException, ConflictException, + ForbiddenException, Inject, Injectable, Logger, NotFoundException, forwardRef, } from "@nestjs/common"; -import { DataSource } from "typeorm"; +import { DataSource, In } from "typeorm"; import { Booking } from "./entities/booking.entity"; import { @@ -18,6 +19,8 @@ import { ConsolidationApprovalsRepository } from "./consolidation-approvals.repo import { BookingsRepository } from "./bookings.repository"; import { BookingsService } from "./bookings.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. */ 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. */ 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. * @@ -53,6 +65,7 @@ export class ConsolidationApprovalService { private readonly bookingsService: BookingsService, private readonly notifier: BookingLifecycleNotifierService, private readonly dataSource: DataSource, + private readonly yardScope: YardScopeService, ) {} /** @@ -116,8 +129,16 @@ export class ConsolidationApprovalService { approvalId: string, decidedBy: string, note?: string, + user?: unknown, ): 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 () => { const claimed = await this.approvals.decide( @@ -125,6 +146,10 @@ export class ConsolidationApprovalService { ConsolidationApprovalStatus.Approved, decidedBy, note, + [ + ConsolidationApprovalStatus.Pending, + ConsolidationApprovalStatus.Rejected, + ], ); // Lost the race to another approver deciding the same pairing. if (!claimed) { @@ -162,13 +187,17 @@ export class ConsolidationApprovalService { approvalId: string, decidedBy: string, reason: string, + user?: unknown, ): Promise<{ booking: Booking; partner: Booking }> { if (!reason?.trim()) { throw new BadRequestException( "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 () => { const claimed = await this.approvals.decide( @@ -212,9 +241,120 @@ export class ConsolidationApprovalService { return { booking, partner }; } - /** Pending pairings awaiting a decision, oldest first. */ - queue(): Promise { - return this.approvals.findQueue(); + /** + * One page of the review queue, or of its history: pending pairings first, + * 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; + 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 (contract–booking 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> { + 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 { + 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. */ @@ -227,12 +367,46 @@ export class ConsolidationApprovalService { return this.approvals.findPendingForBooking(bookingId); } - private async loadPending(approvalId: string): Promise { + /** + * 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 { + 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 { const approval = await this.approvals.findById(approvalId); if (!approval) { throw new NotFoundException(`Approval ${approvalId} not found`); } - if (approval.status !== ConsolidationApprovalStatus.Pending) { + if (!allowed.includes(approval.status)) { throw new ConflictException( `This consolidation was already ${approval.status.toLowerCase()}.`, ); diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation-approvals.repository.ts b/apps/edr-freight-api/src/modules/bookings/consolidation-approvals.repository.ts index b398e7e8c..32d36d039 100644 --- a/apps/edr-freight-api/src/modules/bookings/consolidation-approvals.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/consolidation-approvals.repository.ts @@ -1,11 +1,41 @@ import { Injectable } from "@nestjs/common"; -import { DataSource, In, Repository } from "typeorm"; +import { DataSource, In, Repository, SelectQueryBuilder } from "typeorm"; import { ConsolidationApproval, ConsolidationApprovalStatus, } 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, + 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 — * 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 } }); } - /** Pending requests for the review queue, oldest first (FIFO). */ - findQueue(): Promise { - return this.repository.find({ - where: { status: ConsolidationApprovalStatus.Pending }, - relations: { - booking: { company: true }, - partnerBooking: { company: true }, - }, - order: { requestedAt: "ASC" }, - }); + /** + * One page of review-queue rows, with both bookings loaded. + * + * Pending rows are work still to do, so they come oldest first (FIFO) and + * ahead of everything else. Decided rows are history, so they come + * newest-decision-first. Ordering is done in SQL, not after the fact — a page + * sorted in memory would only be sorted within itself. + * + * `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> { + 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: { @@ -89,9 +194,11 @@ export class ConsolidationApprovalsRepository { | ConsolidationApprovalStatus.Rejected, decidedBy: string | null, decisionNote?: string | null, + /** Statuses the row may be claimed FROM. Defaults to pending-only. */ + from: ConsolidationApprovalStatus[] = [ConsolidationApprovalStatus.Pending], ): Promise { const result = await this.repository.update( - { id, status: ConsolidationApprovalStatus.Pending }, + { id, status: In(from) }, { status, decidedBy, @@ -109,7 +216,10 @@ export class ConsolidationApprovalsRepository { if (bookingIds.length === 0) return Promise.resolve([]); return this.repository.find({ where: [ - { bookingId: In(bookingIds), status: ConsolidationApprovalStatus.Pending }, + { + bookingId: In(bookingIds), + status: ConsolidationApprovalStatus.Pending, + }, { partnerBookingId: In(bookingIds), status: ConsolidationApprovalStatus.Pending, diff --git a/apps/edr-freight-api/src/modules/bookings/dto/additional-charge.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/additional-charge.dto.ts index eb4d095bf..baf135876 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/additional-charge.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/additional-charge.dto.ts @@ -1,6 +1,14 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; 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 { @ApiProperty({ example: 'Re-weighing fee at Mojo dry port' }) @@ -24,6 +32,12 @@ export class CreateAdditionalChargeDto { @IsOptional() @IsIn(['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 { diff --git a/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts index 6a5dfe112..624762fac 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts @@ -67,10 +67,60 @@ export class RequestWagonCancellationDto { reason?: string; } +export class RebookUnitDto { + @ApiProperty({ description: 'Container number for the rebooked unit' }) + @IsString() + @MaxLength(64) + containerNumber!: string; + + @ApiPropertyOptional({ description: 'Seal number' }) + @IsOptional() + @IsString() + @MaxLength(64) + sealNumber?: string; + + @ApiPropertyOptional({ description: 'VGM (tons) of the unit' }) + @IsOptional() + @IsNumber() + @Min(0) + vgmTons?: number; +} + +export class RebookContainerLineDto { + @ApiProperty({ description: 'Container size as stored on the credit, e.g. "20ft"' }) + @IsString() + containerSize!: string; + + @ApiProperty({ + description: + 'The rebooked units for this size — count MUST equal the cancelled quantity', + type: [RebookUnitDto], + }) + @IsArray() + @ArrayNotEmpty() + @ValidateNested({ each: true }) + @Type(() => RebookUnitDto) + units!: RebookUnitDto[]; +} + export class RebookCancelledWagonsDto { @ApiProperty({ description: 'Shipment day the credit is rebooked onto (ISO date)' }) @IsDateString() scheduledDate!: string; + + @ApiPropertyOptional({ + description: + 'Optional unit overrides: container number / seal / VGM may change, but ' + + 'sizes and quantities must match the cancelled booking exactly. Sizes ' + + 'omitted here keep their original units.', + type: [RebookContainerLineDto], + }) + @IsOptional() + @IsArray() + @ArrayNotEmpty() + @ValidateNested({ each: true }) + @Type(() => RebookContainerLineDto) + containers?: RebookContainerLineDto[]; } export class FilterWagonCancellationsDto { diff --git a/apps/edr-freight-api/src/modules/bookings/entities/additional-charge.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/additional-charge.entity.ts index 11ea0bdb1..ba29f0118 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/additional-charge.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/additional-charge.entity.ts @@ -39,6 +39,10 @@ export class AdditionalCharge extends BaseEntity { @Column({ name: 'currency', type: 'varchar', length: 8 }) 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. */ @Column({ name: 'file_record_id', type: 'uuid', nullable: true }) fileRecordId?: string | null; diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-wagon-cancellation.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-wagon-cancellation.entity.ts index 723ea5d1c..950be4575 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-wagon-cancellation.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-wagon-cancellation.entity.ts @@ -5,6 +5,9 @@ import { Invoice } from '../../billing/entities/invoice.entity'; import { Rate } from '../../rule-engine/entities/rate.entity'; import { Booking } from './booking.entity'; +/** `invoices.type` of the wagon-cancellation fee invoice — the settlement branch key in BookingInvoiceService. */ +export const WAGON_CANCEL_FEE_INVOICE_TYPE = 'WAGON_CANCEL_FEE'; + export const WAGON_CANCELLATION_STATUSES = [ // Requested; fee invoice open; wagons still allocated to the customer. 'FEE_PENDING', diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts index 092ebc2c4..17e3631e0 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -3,6 +3,10 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { BaseRepository } from '@edr/api-common'; import { Company } from './entities/company.entity'; +import { + companyDraftSql, + companyPendingChangeRequestSql, +} from './company-scope.sql'; import { ListCompaniesQueryDto } from './dto/list-companies-query.dto'; import { CompanyStatsResponseDto } from './dto/company-stats-response.dto'; @@ -15,31 +19,10 @@ export class CompaniesRepository extends BaseRepository { * placeholder name + TIN, so it must not be offered up for review. * Staff-created companies have no external profiles and are never drafts. */ - private static readonly DRAFT_SQL = `( - 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 DRAFT_SQL = companyDraftSql('company'); - /** - * A company waiting on a reviewer to decide an edit it submitted after being - * 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 - )`; + private static readonly PENDING_CHANGE_REQUEST_SQL = + companyPendingChangeRequestSql('company'); /** * The `sortBy = 'review'` queue ordering: whatever marketing must act on @@ -96,6 +79,9 @@ export class CompaniesRepository extends BaseRepository { type, kind, status, + nationality, + createdFrom, + createdTo, onboardingCompleted, hasPendingChangeRequest, sortBy = 'review', @@ -122,6 +108,18 @@ export class CompaniesRepository extends BaseRepository { 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) { qb.andWhere( onboardingCompleted diff --git a/apps/edr-freight-api/src/modules/companies/company-scope.sql.ts b/apps/edr-freight-api/src/modules/companies/company-scope.sql.ts new file mode 100644 index 000000000..df7a0bc7e --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/company-scope.sql.ts @@ -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 + )`; diff --git a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts index ffb600e36..18b816a2a 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts @@ -1,7 +1,20 @@ 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 { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity"; +import { + CompanyKind, + CompanyNationality, + CompanyStatus, + CompanyType, +} from "../entities/company.entity"; export class ListCompaniesQueryDto { @ApiPropertyOptional({ default: 1 }) @@ -38,6 +51,21 @@ export class ListCompaniesQueryDto { @IsIn(Object.values(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({ description: "Filter by onboarding submission. `true` = reviewable applications; " + diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index e093bd9bf..f4236ce7f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -140,6 +140,14 @@ export class ContractBookingService { dto: CreateBookingUnderContractDto, user?: { id?: string } | null, 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 { const contract = await this.contractsRepository.findByIdWithRelations(contractId); if (!contract) throw new NotFoundException(`Contract ${contractId} not found`); @@ -180,8 +188,13 @@ export class ContractBookingService { actorPermissions != null && hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking); - await this.assertNotExpired(contract); - const createdByRole = await this.assertGate(contract, isGlActor); + if (!opts?.allowExpiredContract) await this.assertNotExpired(contract); + 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 // booking reached a terminal state (e.g. payment expired without shipping), @@ -1203,6 +1216,7 @@ export class ContractBookingService { contract: Contract, isGlActor: boolean, isInitiate = false, + allowExpired = false, ): Promise { // Suspended contracts are frozen for everyone, GL included — say so instead // 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 // 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( '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. - if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) { + if ( + !['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status) && + !(allowExpired && contract.status === 'EXPIRED') + ) { throw new BadRequestException( 'Contract must be fully executed before booking a shipment.', ); @@ -2458,22 +2478,12 @@ export class ContractBookingService { private async assert20ftPairableAtCreate( dto: CreateBookingUnderContractDto, ): Promise { - // Parity gate. 20ft containers ride two per wagon, so an odd total leaves - // one container that cannot be placed. Consolidation (pairing it with - // another customer's odd booking) is built end to end but switched off for - // now, so an odd total is rejected outright — server-side, because the - // frontend block alone is not a guarantee. - const ft20Quantity = (dto.containers ?? []) - .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}).`, - ); - } - + // Odd 20ft totals are no longer rejected here: the wagon consolidation gate + // that runs right after (consolidateDrawdown / needsConsolidationFromBooking, + // same machinery the plain booking flow already uses live) auto-pairs an odd + // total with another customer's odd booking or parks it as + // PENDING_CONSOLIDATION until one appears. This assert now only checks that + // any 20ft containers actually present can be weight-paired on a wagon. const twentyFtUnits = (dto.containers ?? []) .filter((line) => (line.containerSize ?? '').includes('20')) .flatMap((line, lineIdx) => diff --git a/apps/edr-freight-api/src/modules/contracts/contract-expired-rebook-gate.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-expired-rebook-gate.spec.ts new file mode 100644 index 000000000..d44cf4ee6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-expired-rebook-gate.spec.ts @@ -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, + allowExpired: boolean, + ): Promise => + ( + service as unknown as { + assertGate: ( + c: unknown, + gl: boolean, + init: boolean, + allowExpired: boolean, + ) => Promise; + } + ).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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts index 1e2cc9098..c9f2a7d21 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts @@ -13,7 +13,7 @@ import { applyDirectionScope } from '../../user-trade-access/trade-scope.util'; 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` * holds an item COUNT, not tonnage, and `adjusted_total_amount` silently * overrides `total_amount`. Getting either wrong misreports money or weight. diff --git a/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts index 7a01ae048..a840f54a0 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts @@ -1,5 +1,9 @@ import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; import { Company } from '../../companies/entities/company.entity'; +import { + companyDraftSql, + companyPendingChangeRequestSql, +} from '../../companies/company-scope.sql'; import { ExportDataset } from '../export.types'; /** @@ -114,6 +118,21 @@ export const customersDataset: ExportDataset = { { value: 'government', label: 'Government' }, ] }, { 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' }, ], @@ -127,6 +146,15 @@ export const customersDataset: ExportDataset = { if (params.type) qb.andWhere('c.type = :type', { type: params.type }); if (params.kind) qb.andWhere('c.kind = :kind', { kind: params.kind }); 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) { qb.andWhere('(c.name ILIKE :search OR c.tin ILIKE :search OR c.email ILIKE :search)', { search: `%${params.search as string}%`, diff --git a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts index 5eb0986b5..67f2207e9 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts @@ -97,14 +97,21 @@ export const invoicesDataset: ExportDataset = { filters: [ { key: 'issued', label: 'Issued', type: 'daterange' }, + { key: 'due', label: 'Due', type: 'daterange' }, { key: 'statuses', label: 'Status', type: 'multiselect' }, // The invoices list page sends a single `status`; accept both so its // on-screen filter actually carries into the export. { 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: [ { value: 'ETB', label: 'ETB' }, { 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: 'search', label: 'Search invoice no. or customer', type: 'text' }, ], @@ -116,10 +123,27 @@ export const invoicesDataset: ExportDataset = { qb.andWhere('i.deleted_at IS NULL'); 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.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; if (statuses?.length) qb.andWhere('i.status IN (:...statuses)', { statuses }); 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.search) { qb.andWhere('(i.invoice_number ILIKE :search OR c.name ILIKE :search)', { search: `%${params.search as string}%` }); diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts index c631bef92..ae80ea9c8 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts @@ -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 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 { BookingsService } from '../bookings/bookings.service'; import { AssignCustomsRiskDto, CreateDjiboutiIncidentDto, @@ -19,30 +24,38 @@ import { ImportOperationsService } from './import-operations.service'; @ApiBearerAuth() @Controller('import-operations') // Post-booking customs / import-operations actions are GL/Ops work, mirroring the -// contracts controller's GL operational endpoints (risk, duty, milestones). -@BookingStaff(FREIGHT_PERMS.bookings.operations) +// contracts controller's GL operational endpoints (risk, duty, milestones). No +// 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 { - constructor(private readonly service: ImportOperationsService) {} + constructor( + private readonly service: ImportOperationsService, + private readonly bookingsService: BookingsService, + ) {} @Get('djibouti-incidents') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 8: list Djibouti import incidents' }) listIncidents(@Query('bookingId') bookingId?: string) { return this.service.listIncidents(bookingId); } @Post('djibouti-incidents') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 8: report a Djibouti import incident / exception' }) createIncident(@Body() dto: CreateDjiboutiIncidentDto) { return this.service.createIncident(dto); } @Get('customs/:bookingId') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 12: import customs finalization state' }) getCustoms(@Param('bookingId', ParseUUIDPipe) bookingId: string) { return this.service.getCustoms(bookingId); } @Post('customs/:bookingId/documents') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 12: upload IM4/IM5/T1/permit/payment-slip documents' }) uploadCustomsDocument( @Param('bookingId', ParseUUIDPipe) bookingId: string, @@ -52,6 +65,7 @@ export class ImportOperationsController { } @Post('customs/:bookingId/declaration') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 12: record declaration serial number' }) recordDeclaration( @Param('bookingId', ParseUUIDPipe) bookingId: string, @@ -61,6 +75,7 @@ export class ImportOperationsController { } @Post('customs/:bookingId/notify-duties-taxes') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 12: notify duties and taxes' }) notifyDutiesTaxes( @Param('bookingId', ParseUUIDPipe) bookingId: string, @@ -70,6 +85,7 @@ export class ImportOperationsController { } @Post('customs/:bookingId/duties-taxes-paid') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 12: mark duties and taxes paid' }) markDutiesTaxesPaid( @Param('bookingId', ParseUUIDPipe) bookingId: string, @@ -79,12 +95,14 @@ export class ImportOperationsController { } @Post('customs/:bookingId/risk') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 12: assign customs risk' }) assignRisk(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: AssignCustomsRiskDto) { return this.service.assignRisk(bookingId, dto); } @Post('customs/:bookingId/release-permitted') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 12: mark import release permitted' }) markReleasePermitted( @Param('bookingId', ParseUUIDPipe) bookingId: string, @@ -94,18 +112,21 @@ export class ImportOperationsController { } @Get('empty-container-returns') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 16: list empty container returns' }) listEmptyReturns() { return this.service.listEmptyReturns(); } @Post('empty-container-returns') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 16: create an empty container return record' }) createEmptyReturn(@Body() dto: CreateEmptyContainerReturnDto) { return this.service.createEmptyReturn(dto); } @Post('empty-container-returns/load-on-train') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ 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') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 16: advance empty container return workflow' }) updateEmptyReturnStatus( @Param('id', ParseUUIDPipe) id: string, @@ -121,4 +143,53 @@ export class ImportOperationsController { ) { 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 { + 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); + } } diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts index fb4c6e896..21e0dd9a0 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts @@ -1,6 +1,8 @@ import { Module } from '@nestjs/common'; 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 { EmptyContainerReturn } from './entities/empty-container-return.entity'; import { ImportCustomsFinalization } from './entities/import-customs-finalization.entity'; @@ -14,6 +16,11 @@ import { ImportOperationsService } from './import-operations.service'; ImportCustomsFinalization, 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], providers: [ImportOperationsService], diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts index 28eb4e44f..ccec40139 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts @@ -2,6 +2,9 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm import { InjectRepository } from '@nestjs/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 { CreateDjiboutiIncidentDto, CreateEmptyContainerReturnDto, @@ -39,6 +42,8 @@ export class ImportOperationsService { private readonly customs: Repository, @InjectRepository(EmptyContainerReturn) private readonly emptyReturns: Repository, + private readonly pdfDocuments: WarehouseReleaseDocumentService, + private readonly logoSettings: LogoSettingsService, ) {} listIncidents(bookingId?: string) { @@ -150,6 +155,10 @@ export class ImportOperationsService { 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) { const returnDate = dto.returnDate ? new Date(dto.returnDate) : new Date(); return this.emptyReturns.save( @@ -248,6 +257,142 @@ export class ImportOperationsService { return this.emptyReturns.findOneOrFail({ where: { id } }); } + async getEmptyReturnOrThrow(id: string): Promise { + 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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + 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]) => + `${esc(label)}${esc(value)}`, + ) + .join(''); + + return ` + + + + Equipment Interchange Receipt + + + +
+
+ ${logoMarkup(opts.logoImageUrl)} +
Ethio-Djibouti Railway S.C.
+

Equipment Interchange Receipt

+
+
+ Receipt No. + ${esc(`EIR-${row.id.slice(0, 8).toUpperCase()}`)} + Generated: ${esc(new Date().toLocaleString('en-GB'))} +
+
+ + + + ${rowsHtml} + +
+ +
+ 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. +
+ +
+
Depot officer name / signature / date
+
Customer or driver name / signature / date
+
+ +`; + } + private async getOrCreateCustoms(bookingId: string) { const existing = await this.customs.findOne({ where: { bookingId } }); if (existing) return existing; diff --git a/apps/edr-freight-api/src/modules/operations-reporting/dto/create-operations-target.dto.ts b/apps/edr-freight-api/src/modules/operations-reporting/dto/create-operations-target.dto.ts index 2d8f5beb0..07c380cda 100644 --- a/apps/edr-freight-api/src/modules/operations-reporting/dto/create-operations-target.dto.ts +++ b/apps/edr-freight-api/src/modules/operations-reporting/dto/create-operations-target.dto.ts @@ -58,13 +58,18 @@ export class CreateOperationsTargetDto { @ApiPropertyOptional({ 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', }) @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() @MaxLength(60) - cargoCategory?: string; + cargoCategory?: string | null; @ApiPropertyOptional() @IsOptional() diff --git a/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-target.entity.ts b/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-target.entity.ts index aec11ae24..1bcbe908b 100644 --- a/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-target.entity.ts +++ b/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-target.entity.ts @@ -1,8 +1,26 @@ import { BaseEntity } from '@edr/api-common'; 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 (Oct–Dec + * and the 5–6 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]; /** What is being planned. */ @@ -31,9 +49,13 @@ export const TARGET_DIMENSION_LABELS: Record = { }; export const TARGET_PERIOD_LABELS: Record = { + day: 'Daily', week: 'Weekly', month: 'Monthly', quarter: 'Quarterly', + half_year: 'Half-yearly', + nine_month: 'Nine-monthly', + ninety_day: '90-day', year: 'Yearly', }; diff --git a/apps/edr-freight-api/src/modules/operations-reporting/operations-reporting.module.ts b/apps/edr-freight-api/src/modules/operations-reporting/operations-reporting.module.ts index 180c12271..e2da0eb30 100644 --- a/apps/edr-freight-api/src/modules/operations-reporting/operations-reporting.module.ts +++ b/apps/edr-freight-api/src/modules/operations-reporting/operations-reporting.module.ts @@ -1,4 +1,4 @@ -import { Global, Module } from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; 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 * actuals against. * - * Global because the reports module reads the standards row on every run and - * has no other reason to import this. + * Not global, and deliberately so: nothing outside this module injects either + * 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({ imports: [TypeOrmModule.forFeature([OperationsStandard, OperationsTarget])], controllers: [OperationsStandardsController, OperationsTargetsController], diff --git a/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.spec.ts b/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.spec.ts new file mode 100644 index 000000000..69ad38db6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.spec.ts @@ -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'); + // Oct–Dec 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([]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts b/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts index c33054aa5..fbcbc6d7b 100644 --- a/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts +++ b/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts @@ -1,5 +1,10 @@ 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 { Brackets, IsNull, Repository } from 'typeorm'; @@ -12,6 +17,8 @@ import { TARGET_DIMENSION_LABELS, TARGET_METRIC_LABELS, TARGET_PERIOD_LABELS, + TargetDimension, + TargetMetric, TargetPeriodType, } from './entities/operations-target.entity'; import { @@ -19,10 +26,19 @@ import { CONTAINER_CLASSES, } from '../reports/operations-classification'; +const MS_PER_DAY = 86_400_000; + /** - * Normalises any date inside a bucket to the bucket's first day, matching - * Postgres `date_trunc` — which is what the reports group by. Week starts - * Monday, the same as `date_trunc('week', …)` and ISO week numbering. + * Normalises any date inside a bucket to the bucket's first day, matching the + * bucket expression the reports group by (`PERIOD_UNITS` in + * `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 * 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 { const d = new Date(`${value.slice(0, 10)}T00:00:00Z`); switch (periodType) { + case 'day': + break; case 'week': { // getUTCDay(): 0 = Sunday. Monday-based offset puts Sunday six days in. const offset = (d.getUTCDay() + 6) % 7; @@ -43,6 +61,22 @@ export function normalisePeriodStart(periodType: TargetPeriodType, value: string case 'quarter': d.setUTCMonth(Math.floor(d.getUTCMonth() / 3) * 3, 1); break; + case 'half_year': + d.setUTCMonth(Math.floor(d.getUTCMonth() / 6) * 6, 1); + break; + case 'nine_month': + // Two blocks a year, not 1.33: Jan–Sep, then a short Oct–Dec. + 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': d.setUTCMonth(0, 1); break; @@ -76,6 +110,27 @@ const LABELS_BY_DIMENSION: Record> = { 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, Set> = { + 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() export class OperationsTargetsService { constructor( @@ -152,35 +207,113 @@ export class OperationsTargetsService { } async create(dto: CreateOperationsTargetDto): Promise { - const periodStart = normalisePeriodStart(dto.periodType, dto.periodStart); - const cargoCategory = dto.cargoCategory ?? null; - await this.assertSlotFree({ ...dto, periodStart, cargoCategory }); - return this.repository.save(this.repository.create({ ...dto, periodStart, cargoCategory })); + const slot = await this.resolveSlot(dto); + await this.assertSlotFree(slot); + return this.repository.save(this.repository.create({ ...dto, ...slot })); } async update(id: string, dto: UpdateOperationsTargetDto): Promise { const current = await this.findById(id); - const periodType = dto.periodType ?? current.periodType; - const periodStart = normalisePeriodStart(periodType, dto.periodStart ?? current.periodStart); - const next = { - periodType, - periodStart, + const slot = await this.resolveSlot({ + periodType: dto.periodType ?? current.periodType, + periodStart: dto.periodStart ?? current.periodStart, metric: dto.metric ?? current.metric, dimension: dto.dimension ?? current.dimension, 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: - dto.cargoCategory !== undefined ? (dto.cargoCategory ?? null) : current.cargoCategory ?? null, - }; - await this.assertSlotFree(next, id); + dto.cargoCategory !== undefined ? dto.cargoCategory : current.cargoCategory, + }); + await this.assertSlotFree(slot, id); await this.repository.update(id, { - ...next, + ...slot, ...(dto.plannedValue != null ? { plannedValue: dto.plannedValue } : {}), ...(dto.note !== undefined ? { note: dto.note } : {}), }); 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 { + 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 { + 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 { await this.findById(id); await this.repository.softDelete(id); diff --git a/apps/edr-freight-api/src/modules/overview/dto/overview-layout.dto.ts b/apps/edr-freight-api/src/modules/overview/dto/overview-layout.dto.ts new file mode 100644 index 000000000..8a125bf81 --- /dev/null +++ b/apps/edr-freight-api/src/modules/overview/dto/overview-layout.dto.ts @@ -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::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; +} diff --git a/apps/edr-freight-api/src/modules/overview/overview.controller.ts b/apps/edr-freight-api/src/modules/overview/overview.controller.ts index fcec82d4d..37d0b77d2 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.controller.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.controller.ts @@ -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 { 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 { OverviewResponseDto } from './dto/overview-response.dto'; import { @@ -34,6 +40,22 @@ export class OverviewController { 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() @BookingStaff(FREIGHT_PERMS.overview.view) @ApiOperation({ summary: 'Aggregated dashboard summary for backoffice overview' }) diff --git a/apps/edr-freight-api/src/modules/reports/definitions/bookings-list.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/bookings-list.report.ts deleted file mode 100644 index ca476cea3..000000000 --- a/apps/edr-freight-api/src/modules/reports/definitions/bookings-list.report.ts +++ /dev/null @@ -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, -): SelectQueryBuilder { - 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' }, - ]; - }, -}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-by-station.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-by-station.report.ts index dd111d22a..c63a28f05 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-by-station.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-by-station.report.ts @@ -13,6 +13,7 @@ import { TEU_EXPR, allocationLedgerQb, applyCategoryFilter, + attainmentCtx, PLAN_GRANULARITY_NOTE, implementRateExpr, plannedRowsParams, @@ -86,6 +87,7 @@ export const cargoVolumeByStationReport: ReportDefinition = { { key: 'category', label: 'Cargo type', type: 'string', sortable: true }, { key: 'operated', label: 'Operated', type: 'tons', sortable: true }, { key: 'plan', label: 'Plan', type: 'tons' }, + { key: 'planRequired', label: 'Required', type: 'tons' }, { key: 'implementRate', label: 'Implement rate', type: 'percent' }, { key: 'teu', label: 'TEU', type: 'number' }, { key: 'wagons', label: 'Wagons', type: 'number' }, @@ -118,6 +120,18 @@ export const cargoVolumeByStationReport: ReportDefinition = { .addGroupBy(originationExpr(params, 'code')) .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 // 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. @@ -134,9 +148,15 @@ export const cargoVolumeByStationReport: ReportDefinition = { COALESCE(o.teu, 0) AS teu, COALESCE(o.wagons, 0) AS wagons, 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 - 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 AND p.plan_key = o.station_code AND p.plan_category = o.category_key`; @@ -144,7 +164,11 @@ export const cargoVolumeByStationReport: ReportDefinition = { return ctx.ds .createQueryBuilder() .from(`(${combined})`, 'r') - .setParameters({ ...operated.getParameters(), ...plannedRowsParams(params) }) + .setParameters({ + ...operated.getParameters(), + ...attained.getParameters(), + ...plannedRowsParams(params), + }) .select('r.period', 'period') .addSelect('r.station', 'station') .addSelect('r.origination', 'origination') @@ -152,6 +176,7 @@ export const cargoVolumeByStationReport: ReportDefinition = { .addSelect('r.category_key', 'categoryKey') .addSelect('r.operated::float8', 'operated') .addSelect('r.plan::float8', 'plan') + .addSelect('r.plan_required::float8', 'planRequired') .addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate') .addSelect('r.teu::int', 'teu') .addSelect('r.wagons::int', 'wagons') diff --git a/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-performance.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-performance.report.ts index 68c255c11..2bb02b859 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-performance.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-performance.report.ts @@ -13,6 +13,7 @@ import { TEU_EXPR, allocationLedgerQb, applyCategoryFilter, + attainmentCtx, PLAN_GRANULARITY_NOTE, implementRateExpr, plannedRowsParams, @@ -42,6 +43,7 @@ export const cargoVolumePerformanceReport: ReportDefinition = { { key: 'category', label: 'Cargo category', type: 'string', sortable: true }, { key: 'operated', label: 'Operated', type: 'tons', sortable: true }, { key: 'plan', label: 'Plan', type: 'tons' }, + { key: 'planRequired', label: 'Required', type: 'tons' }, { key: 'implementRate', label: 'Implement rate', type: 'percent' }, { key: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true }, { key: 'teu', label: 'TEU', type: 'number', sortable: true }, @@ -63,6 +65,17 @@ export const cargoVolumePerformanceReport: ReportDefinition = { .groupBy(bucket) .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 // reports its miss instead of disappearing from the table. const combined = ` @@ -73,20 +86,31 @@ export const cargoVolumePerformanceReport: ReportDefinition = { COALESCE(o.teu, 0) AS teu, COALESCE(o.wagons, 0) AS wagons, 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 - 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`; return ctx.ds .createQueryBuilder() .from(`(${combined})`, 'r') - .setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) }) + .setParameters({ + ...operated.getParameters(), + ...attained.getParameters(), + ...plannedRowsParams(ctx.params), + }) .select('r.period', 'period') .addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category') .addSelect('r.category_key', 'categoryKey') .addSelect('r.operated::float8', 'operated') .addSelect('r.plan::float8', 'plan') + .addSelect('r.plan_required::float8', 'planRequired') .addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate') .addSelect('r.charged_tons::float8', 'chargedTons') .addSelect('r.teu::int', 'teu') diff --git a/apps/edr-freight-api/src/modules/reports/definitions/contract-lifecycle.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/contract-lifecycle.report.ts deleted file mode 100644 index 513abeda4..000000000 --- a/apps/edr-freight-api/src/modules/reports/definitions/contract-lifecycle.report.ts +++ /dev/null @@ -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 { - 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) }, - ]; - }, -}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/customer-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/customer-status.report.ts deleted file mode 100644 index 60b6ff31a..000000000 --- a/apps/edr-freight-api/src/modules/reports/definitions/customer-status.report.ts +++ /dev/null @@ -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 { - 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) }, - ]; - }, -}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/invoices-by-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/invoices-by-status.report.ts deleted file mode 100644 index 81b183f90..000000000 --- a/apps/edr-freight-api/src/modules/reports/definitions/invoices-by-status.report.ts +++ /dev/null @@ -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 { - 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' }, - ]; - }, -}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/payments-by-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/payments-by-status.report.ts deleted file mode 100644 index 15e99a4b1..000000000 --- a/apps/edr-freight-api/src/modules/reports/definitions/payments-by-status.report.ts +++ /dev/null @@ -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 { - 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' }, - ]; - }, -}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.spec.ts b/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.spec.ts new file mode 100644 index 000000000..76edd2238 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.spec.ts @@ -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\./); + } + }); +}); diff --git a/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts index 018a68faf..0e07710bb 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts @@ -1,5 +1,12 @@ 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 { PAYER_EXPR, @@ -10,47 +17,287 @@ import { } from '../revenue-classification'; export const LEDGER_SIDES: ReportFilterOption[] = [ - { value: 'RECEIVABLE_CREDIT', label: 'Receivable — credit service (shipping line)' }, - { value: 'RECEIVABLE_OPEN', label: 'Receivable — open balance' }, - { value: 'PAYABLE_CANCELLATION', label: 'Payable — cancellation fee' }, - { value: 'PAYABLE_UNDELIVERED', label: 'Payable — paid but not delivered' }, - { value: 'SETTLED', label: 'Settled' }, + { + value: 'RECEIVABLE_SL_UNBILLED', + label: 'Receivable — shipping-line service, not yet invoiced', + }, + { + 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 - * arrangement, plus any invoice still carrying a balance. - * Payable = the customer paid for something EDR did not deliver, so the money - * is a refund liability rather than revenue: cancellation fees, and prepaid - * invoices whose booking died. + * RECEIVABLE — money EDR is owed. The shipping-line arrangement is service + * first, pay later, and it produces debt in two shapes: a `shipping_line_credits` + * row with NO invoice while it is UNBILLED (a shipping-line booking raises no + * invoice at all), and an open batch invoice once finance bills it. Counting + * 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 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`; -/** Money at stake on this row: what is owed, or what may have to be given back. */ -const EXPOSURE = `CASE - WHEN ${SIDE_EXPR} LIKE 'PAYABLE%' THEN i.paid_amount - ELSE i.balance_amount -END`; +/** + * The union's column contract, in positional order. + * + * UNION matches by POSITION, and TypeORM does not preserve `addSelect` order — + * 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, + ctx: ReportContext, + dateExpr: string, +): SelectQueryBuilder { + 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 { + 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 { + 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 { + 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 { - 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; - if (sides?.length) qb.andWhere(`${SIDE_EXPR} IN (:...sides)`, { sides }); + if (sides?.length) qb.andWhere('r.side_key IN (:...sides)', { sides }); + return qb; } @@ -58,57 +305,113 @@ export const receivablesPayablesReport: ReportDefinition = { key: 'receivables-payables', title: 'Receivables and Payables', description: - 'Splits customer money two ways: receivable, where EDR delivered and is owed — ' + - 'including shipping-line credit services — and payable, where the customer paid but ' + - 'the service was not delivered, such as cancellation fees and prepayments against ' + - 'dead bookings. Payable amounts are a refund liability, not revenue.', + 'Splits open customer money two ways: receivable, where EDR delivered and is owed — ' + + 'shipping-line credit services whether invoiced yet or not, plus any invoice still ' + + 'carrying a balance — and payable, where the customer paid and the service was not ' + + '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', filters: [ ...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: [ - { key: 'side', label: 'Ledger side', type: 'string', sortable: true, sortExpr: SIDE_EXPR }, - { key: 'issuedAt', label: 'Issued', type: 'date', sortable: true, sortExpr: REVENUE_DATE }, - { key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' }, + { + key: 'side', + 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: '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: 'paid', label: 'Paid', type: 'money', sortable: true, sortExpr: 'i.paid_amount' }, - { key: 'exposure', label: 'Owed / refundable', type: 'money', sortable: true, sortExpr: EXPOSURE }, + { + key: 'customer', + label: 'Payer', + 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' }, chart: { type: 'bar', x: 'side', y: ['exposure'] }, query(ctx) { return baseQuery(ctx) - .select(SIDE_LABEL_EXPR, 'side') - .addSelect(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD')`, 'issuedAt') - .addSelect('i.invoice_number', 'invoiceNumber') - .addSelect("COALESCE(b.reference, '—')", 'bookingRef') - .addSelect("COALESCE(b.status, '—')", 'bookingStatus') - .addSelect(PAYER_EXPR, 'customer') - .addSelect('ROUND(i.total_amount, 2)::float8', 'invoiced') - .addSelect('ROUND(i.paid_amount, 2)::float8', 'paid') - .addSelect(`ROUND(${EXPOSURE}, 2)::float8`, 'exposure'); + .select(SIDE_LABEL_OF('r.side_key'), 'side') + .addSelect("to_char(r.txn_date, 'YYYY-MM-DD')", 'issuedAt') + .addSelect('r.doc_ref', 'invoiceNumber') + .addSelect('r.booking_ref', 'bookingRef') + .addSelect('r.booking_status', 'bookingStatus') + .addSelect('r.payer', 'customer') + .addSelect('ROUND(r.gross, 2)::float8', 'invoiced') + .addSelect('ROUND(r.settled, 2)::float8', 'paid') + .addSelect('ROUND(r.exposure, 2)::float8', 'exposure'); }, async summary(ctx) { const row = await baseQuery(ctx) .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', ) .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', ) - .addSelect('COUNT(*)::int', 'invoices') - .getRawOne<{ receivable: number; payable: number; invoices: number }>(); + .addSelect('COUNT(*)::int', 'items') + .getRawOne<{ receivable: number; payable: number; items: number }>(); + + const receivable = Number(row?.receivable ?? 0); + const payable = Number(row?.payable ?? 0); const currency = currencyOf(ctx.params); return [ - { label: 'Receivable', value: Number(row?.receivable ?? 0), unit: currency }, - { label: 'Payable', value: Number(row?.payable ?? 0), unit: currency }, - { label: 'Invoices', value: Number(row?.invoices ?? 0) }, + { label: 'Receivable', value: receivable, unit: currency }, + { label: 'Payable', value: payable, unit: currency }, + { + label: 'Net position', + value: Math.round(receivable - payable), + unit: currency, + }, + { label: 'Open items', value: Number(row?.items ?? 0) }, ]; }, }; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-category.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-category.report.ts index 0a46f15ec..151cd4cc3 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-category.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-category.report.ts @@ -3,9 +3,10 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; import { ReportContext, ReportDefinition } from '../report.types'; import { AVG_PER_UNIT_EXPR, - CATEGORY_LABEL_EXPR, + CATEGORY_LABEL_OF, CONTAINERS_EXPR, PERIOD_FILTER, + REVENUE_CATEGORIES, REVENUE_CATEGORY_EXPR, REVENUE_FILTERS, REVENUE_SUM, @@ -21,19 +22,35 @@ import { 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 - * legal alongside the SUM — no self-join, no CTE. Both the PARTITION BY and the - * ORDER BY must repeat their grouping expressions verbatim: ordering by the - * inner `date_trunc` when the group key is the `to_char` wrapper fails, and - * ordinal shorthand (`ORDER BY 1`) is read as a constant inside a window - * clause, silently producing an unordered partition. + * The window runs in the OUTER query, not alongside the aggregate. `lag()` only + * ever sees the rows its own query level produces, so computing it inside the + * aggregate would skip straight over a category's silent periods — a category + * billed in January and March would read March's prior as January and report + * flat growth, hiding the month it earned nothing. Against the grid, February + * exists at zero and both comparisons are real. */ -const priorRevenue = (period: string): string => - `lag(${REVENUE}) OVER (PARTITION BY ${REVENUE_CATEGORY_EXPR} ORDER BY ${period})`; +const PRIOR_REVENUE = 'lag(r.revenue) OVER (PARTITION BY r.category_key ORDER BY r.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[] => { + 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 { return revenueLedgerQb(ctx); @@ -44,7 +61,9 @@ export const revenueByCategoryReport: ReportDefinition = { title: 'Revenue by Category', description: '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. ' + 'Multimodal means a named sea carrier is on the booking.', group: 'Finance', @@ -81,21 +100,89 @@ export const revenueByCategoryReport: ReportDefinition = { }, query(ctx) { 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') - .addSelect(CATEGORY_LABEL_EXPR, 'category') - .addSelect(REVENUE_CATEGORY_EXPR, 'categoryKey') - .addSelect(`ROUND(${REVENUE})::float8`, 'revenue') - .addSelect(`ROUND(COALESCE(${priorRevenue(period)}, 0))::float8`, 'priorRevenue') - .addSelect(`COALESCE(${growthPct(period)}, 0)`, 'growthPct') + .addSelect(REVENUE_CATEGORY_EXPR, 'category_key') + .addSelect(REVENUE, 'revenue') .addSelect(`ROUND(COALESCE(${TONS_EXPR}, 0), 1)::float8`, 'tons') .addSelect(`ROUND(COALESCE(${TEU_EXPR}, 0))::int`, 'teu') .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('COUNT(*)::int', 'lines') .groupBy(period) .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) { const row = await baseQuery(ctx) @@ -113,7 +200,10 @@ export const revenueByCategoryReport: ReportDefinition = { const currency = currencyOf(ctx.params); return [ { 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. { label: 'Unclassified', value: Number(row?.unclassified ?? 0), unit: currency }, ]; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-customer.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-customer.report.ts index 31b9951b8..4312bbef6 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-customer.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-customer.report.ts @@ -1,91 +1,101 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; -import { Booking } from '../../bookings/entities/booking.entity'; -import { Company } from '../../companies/entities/company.entity'; -import { ReportContext, ReportDefinition } from '../report.types'; +import { ReportContext, ReportColumn, ReportDefinition } from '../report.types'; +import { + 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)'; -const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')"; -const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED']; +/** + * One column per payment class, pivoted with FILTER. The class values are the + * compile-time constants in PAYMENT_CLASSES, never user input, so they are + * 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 { - const { params, directions } = 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; + return revenueLedgerQb(ctx); } export const revenueByCustomerReport: ReportDefinition = { key: 'revenue-by-customer', title: 'Revenue by Customer', - description: 'Ranked customers by booking 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' }, - ], + description: + 'Every paying customer on one row: total billed revenue, what they have settled, ' + + 'what is still open, and a column per charge type — rail transport, customs ' + + 'clearance, first/last mile, overweight, cancellation, demurrage, storage, loading ' + + 'and unloading, and additional charges. Built on invoice lines, so the charge-type ' + + 'split is the billed one; a booking total is a lump sum and cannot be split. The ' + + 'payer is the company or, for shipping-line credit invoices, the shipping line. ' + + 'There is no dedicated loading/unloading charge type in the system — handling, ' + + 'double-handling and lashing stand in for it.', + group: 'Finance', + filters: REVENUE_FILTERS, columns: [ - { key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' }, - { key: 'bookings', label: 'Bookings', type: 'number', sortable: true }, - { key: 'tons', label: 'Tonnage', type: 'tons', sortable: true }, - { key: 'revenue', label: 'Revenue', type: 'money', sortable: true }, + { + key: 'customer', + label: 'Customer', + 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' }, + chart: { type: 'bar', x: 'customer', y: ['revenue'] }, + drill: { to: 'revenue-transactions', carry: { customer: 'customer' } }, query(ctx) { - return baseQuery(ctx) - .select('c.name', 'customer') - .addSelect('COUNT(*)::int', 'bookings') - .addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons') - .addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue') - .groupBy('c.name'); + const qb = baseQuery(ctx) + .select(PAYER_EXPR, 'customer') + .addSelect(REVENUE_SUM, 'revenue') + .addSelect(`ROUND(COALESCE(SUM(${PAID_SHARE}), 0))::float8`, 'paid') + .addSelect(`ROUND(COALESCE(SUM(il.amount - (${PAID_SHARE})), 0))::float8`, 'outstanding') + .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) { const row = await baseQuery(ctx) - .select('COUNT(DISTINCT c.name)::int', 'customers') - .addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue') - .getRawOne(); + .select(`COUNT(DISTINCT ${PAYER_EXPR})::int`, 'customers') + .addSelect(REVENUE_SUM, 'revenue') + .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 [ { 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 }, ]; }, }; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-summary.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-summary.report.ts deleted file mode 100644 index 512e05be8..000000000 --- a/apps/edr-freight-api/src/modules/reports/definitions/revenue-summary.report.ts +++ /dev/null @@ -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 { - 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' }, - ]; - }, -}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/teu-performance.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/teu-performance.report.ts index 38a8ef04f..adc108a02 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/teu-performance.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/teu-performance.report.ts @@ -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 { CONTAINER_CLASSES, CONTAINER_CLASS_EXPR, @@ -10,12 +10,13 @@ import { OPERATIONS_FILTERS, TEU_EXPR, allocationLedgerQb, + attainmentCtx, PLAN_GRANULARITY_NOTE, implementRateExpr, plannedRowsParams, plannedRowsSql, -} from '../operations-classification'; -import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-classification'; +} from "../operations-classification"; +import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from "../revenue-classification"; const CONTAINERS_20 = `COALESCE(SUM(( SELECT COUNT(*) FROM freight.wagon_allocation_container_items ci @@ -39,44 +40,53 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder { } export const teuPerformanceReport: ReportDefinition = { - key: 'teu-performance', - title: 'TEU Performance', + key: "teu-performance", + title: "TEU Performance", description: - '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 ' + - 'marshalling record — the containers actually allocated to wagons — not from the ' + - 'billing lines. Plan comes from Operational targets.' + + "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 " + + "marshalling record — the containers actually allocated to wagons — not from the " + + "billing lines. Plan comes from Operational targets." + PLAN_GRANULARITY_NOTE, - group: 'Operations', + group: "Operations", filters: [ PERIOD_FILTER, ...OPERATIONS_FILTERS, - { key: 'classes', label: 'Container class', type: 'multiselect', options: CONTAINER_CLASSES }, + { key: "classes", label: "Container class", type: "multiselect", options: CONTAINER_CLASSES }, ], columns: [ - { key: 'period', label: 'Period', type: 'string', sortable: true }, - { key: 'containerClass', label: 'Container type', type: 'string', sortable: true }, - { key: 'containers20', label: '20ft', 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: 'plan', label: 'Plan', type: 'number' }, - { key: 'implementRate', label: 'Implement rate', type: 'percent' }, + { key: "period", label: "Period", type: "string", sortable: true }, + { key: "containerClass", label: "Container type", type: "string", sortable: true }, + { key: "containers20", label: "20ft", type: "number", sortable: true }, + { key: "containers40", label: "40ft", type: "number", sortable: true }, + { key: "operated", label: "Operated (TEU)", type: "number", sortable: true }, + { key: "plan", label: "Plan", type: "number" }, + { key: "planRequired", label: "Required", type: "number" }, + { key: "implementRate", label: "Implement rate", type: "percent" }, ], - defaultSort: { key: 'operated', dir: 'DESC' }, - chart: { type: 'bar', x: 'containerClass', y: ['operated'] }, + defaultSort: { key: "operated", dir: "DESC" }, + chart: { type: "bar", x: "containerClass", y: ["operated"] }, query(ctx) { const bucket = periodTruncExprOn(OPS_DATE, ctx.params); const operated = baseQuery(ctx) - .select(periodExprOn(OPS_DATE, ctx.params), 'period') - .addSelect(CONTAINER_CLASS_EXPR, 'class_key') - .addSelect(CONTAINERS_20, 'containers20') - .addSelect(CONTAINERS_40, 'containers40') - .addSelect(CONTAINERS_EXPR, 'containers') - .addSelect(TEU_EXPR, 'operated') + .select(periodExprOn(OPS_DATE, ctx.params), "period") + .addSelect(CONTAINER_CLASS_EXPR, "class_key") + .addSelect(CONTAINERS_20, "containers20") + .addSelect(CONTAINERS_40, "containers40") + .addSelect(TEU_EXPR, "operated") .groupBy(bucket) .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 // reports, at zero rather than vanishing. const combined = ` @@ -84,38 +94,47 @@ export const teuPerformanceReport: ReportDefinition = { COALESCE(o.class_key, p.plan_key) AS class_key, COALESCE(o.containers20, 0) AS containers20, COALESCE(o.containers40, 0) AS containers40, - COALESCE(o.containers, 0) AS containers, 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 - 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`; return ctx.ds .createQueryBuilder() - .from(`(${combined})`, 'r') - .setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) }) - .select('r.period', 'period') - .addSelect(CONTAINER_CLASS_LABEL_OF('r.class_key'), 'containerClass') - .addSelect('r.class_key', 'containerClassKey') - .addSelect('r.containers20::int', 'containers20') - .addSelect('r.containers40::int', 'containers40') - .addSelect('r.containers::int', 'containers') - .addSelect('r.operated::int', 'operated') - .addSelect('r.plan::float8', 'plan') - .addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate'); + .from(`(${combined})`, "r") + .setParameters({ + ...operated.getParameters(), + ...attained.getParameters(), + ...plannedRowsParams(ctx.params), + }) + .select("r.period", "period") + .addSelect(CONTAINER_CLASS_LABEL_OF("r.class_key"), "containerClass") + .addSelect("r.class_key", "containerClassKey") + .addSelect("r.containers20::int", "containers20") + .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) { const row = await baseQuery(ctx) - .select(TEU_EXPR, 'teu') - .addSelect(CONTAINERS_EXPR, 'containers') - .addSelect('COUNT(DISTINCT ts.id)::int', 'trains') + .select(TEU_EXPR, "teu") + .addSelect(CONTAINERS_EXPR, "containers") + .addSelect("COUNT(DISTINCT ts.id)::int", "trains") .getRawOne<{ teu: number; containers: number; trains: number }>(); return [ - { label: 'TEU', value: Number(row?.teu ?? 0) }, - { label: 'Containers', value: Number(row?.containers ?? 0) }, - { label: 'Trains', value: Number(row?.trains ?? 0) }, + { label: "TEU", value: Number(row?.teu ?? 0) }, + { label: "Containers", value: Number(row?.containers ?? 0) }, + { label: "Trains", value: Number(row?.trains ?? 0) }, ]; }, }; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/trainset-performance.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/trainset-performance.report.ts index 8e1c52434..385b69dc2 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/trainset-performance.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/trainset-performance.report.ts @@ -13,6 +13,7 @@ import { TRAINSETS_EXPR, allocationLedgerQb, applyCategoryFilter, + attainmentCtx, PLAN_GRANULARITY_NOTE, implementRateExpr, plannedRowsParams, @@ -45,6 +46,7 @@ export const trainsetPerformanceReport: ReportDefinition = { { key: 'wagons', label: 'Wagons', type: 'number', sortable: true }, { key: 'operated', label: 'Operated (trainsets)', type: 'number', sortable: true }, { key: 'plan', label: 'Plan', type: 'number' }, + { key: 'planRequired', label: 'Required', type: 'number' }, { key: 'implementRate', label: 'Implement rate', type: 'percent' }, ], defaultSort: { key: 'operated', dir: 'DESC' }, @@ -60,6 +62,16 @@ export const trainsetPerformanceReport: ReportDefinition = { .groupBy(bucket) .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, // at zero — TypeORM's builder has no full-outer join, hence the raw text. const combined = ` @@ -68,15 +80,25 @@ export const trainsetPerformanceReport: ReportDefinition = { COALESCE(o.trains, 0) AS trains, COALESCE(o.wagons, 0) AS wagons, 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 - 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`; return ctx.ds .createQueryBuilder() .from(`(${combined})`, 'r') - .setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) }) + .setParameters({ + ...operated.getParameters(), + ...attained.getParameters(), + ...plannedRowsParams(ctx.params), + }) .select('r.period', 'period') .addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category') .addSelect('r.category_key', 'categoryKey') @@ -84,6 +106,7 @@ export const trainsetPerformanceReport: ReportDefinition = { .addSelect('r.wagons::int', 'wagons') .addSelect('r.operated::float8', 'operated') .addSelect('r.plan::float8', 'plan') + .addSelect('r.plan_required::float8', 'planRequired') .addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate'); }, async summary(ctx) { diff --git a/apps/edr-freight-api/src/modules/reports/operations-classification.ts b/apps/edr-freight-api/src/modules/reports/operations-classification.ts index ed39536b7..1c8f8504c 100644 --- a/apps/edr-freight-api/src/modules/reports/operations-classification.ts +++ b/apps/edr-freight-api/src/modules/reports/operations-classification.ts @@ -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 - * spread evenly across the days it covers, then re-gathered into the report's - * buckets. One rule covers every direction — three monthly targets add up to a + * **Plan** — a target is a rate over its own period, not a lump at its start. + * The committed value is spread evenly across the days it covers and + * 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 - * 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 - * figure carries no information about which days inside it were busier. + * **Required** — the same target read as a quota. At each bucket, whatever is + * 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 - * the plan always covers exactly the span the operated figure beside it covers. - * Without that, filtering to July and viewing by year would put a whole year's - * plan next to one month's work. + * `actualsSql` must produce `(bucket, act_key, act_category, actual)` and must + * be built **without the user's date bounds** — see {@link attainmentCtx}. + * Attainment is a fact about the target's whole period; measuring it through + * 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 * 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 * 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 = ( metric: string, dimension: string, params: Record, + actualsSql: string, ): string => { const unit = resolvePeriod(params); + // Reused verbatim in the GROUP BY, per the trap documented on `periodExpr`. + const bucketOf = unit.truncOn('d.day'); return ` - SELECT to_char(g.bucket, '${unit.fmt}') AS period, - ot.dimension_key AS plan_key, - ot.cargo_category AS plan_category, - SUM(ot.planned_value * ( - GREATEST(0, EXTRACT(EPOCH FROM ( - LEAST(g.bucket + INTERVAL '${unit.step}', t.ends, ${PLAN_TO}) - - GREATEST(g.bucket, ot.period_start::timestamptz, ${PLAN_FROM})))) - / NULLIF(EXTRACT(EPOCH FROM (t.ends - ot.period_start)), 0) - )) AS plan_value - FROM freight.operations_targets ot - CROSS JOIN LATERAL ( - SELECT ot.period_start + CASE ot.period_type - WHEN 'week' THEN INTERVAL '7 days' - WHEN 'month' THEN INTERVAL '1 month' - WHEN 'quarter' THEN INTERVAL '3 months' - WHEN 'year' THEN INTERVAL '1 year' - ELSE INTERVAL '1 day' - END AS ends - ) t - CROSS JOIN LATERAL generate_series( - date_trunc('${unit.trunc}', ot.period_start::timestamptz), - date_trunc('${unit.trunc}', t.ends - INTERVAL '1 microsecond'), - INTERVAL '${unit.step}' - ) AS g(bucket) - WHERE ot.deleted_at IS NULL - AND ot.metric = '${metric}' - AND ot.dimension = '${dimension}' - AND g.bucket + INTERVAL '${unit.step}' > ${PLAN_FROM} - AND g.bucket < ${PLAN_TO} - GROUP BY 1, 2, 3 - HAVING SUM(ot.planned_value) > 0`; + WITH tgt AS ( + SELECT ot.id, + ot.dimension_key, + ot.cargo_category, + ot.planned_value, + ot.period_start::timestamptz AS starts, + ot.period_start::timestamptz + ${TARGET_SPAN} AS ends + FROM freight.operations_targets ot + WHERE ot.deleted_at IS NULL + AND ot.metric = '${metric}' + AND ot.dimension = '${dimension}' + AND ot.planned_value > 0 + ), + -- One row per target per bucket. Generated a day at a time rather than a + -- bucket at a time: the ragged units restart their blocks each January, so + -- stepping by the unit's own width walks off the anchor in the second year. + -- Day grain also makes a bucket that only partly overlaps the target fall out + -- for free, at the same sub-day precision the clipping used before. + spread AS ( + SELECT t.id, + t.dimension_key, + t.cargo_category, + t.planned_value, + EXTRACT(EPOCH FROM (t.ends - t.starts)) AS secs_total, + ${bucketOf} AS bucket, + SUM(GREATEST(0, EXTRACT(EPOCH FROM ( + LEAST(d.day + INTERVAL '1 day', t.ends) + - GREATEST(d.day, t.starts))))) AS secs_full, + SUM(GREATEST(0, EXTRACT(EPOCH FROM ( + LEAST(d.day + INTERVAL '1 day', t.ends, ${PLAN_TO}) + - 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. */ export const plannedRowsParams = ( params: Record, diff --git a/apps/edr-freight-api/src/modules/reports/report.registry.ts b/apps/edr-freight-api/src/modules/reports/report.registry.ts index fedc8a6dd..fc404738d 100644 --- a/apps/edr-freight-api/src/modules/reports/report.registry.ts +++ b/apps/edr-freight-api/src/modules/reports/report.registry.ts @@ -1,45 +1,39 @@ -import { ReportKey } from '../../seed/freight-permissions.registry'; -import { bookingsListReport } from './definitions/bookings-list.report'; -import { revenueByCustomerReport } from './definitions/revenue-by-customer.report'; -import { agingReceivablesReport } from './definitions/aging-receivables.report'; -import { contractUtilizationReport } from './definitions/contract-utilization.report'; -import { wagonFleetStatusReport } from './definitions/wagon-fleet-status.report'; -import { wagonStatusDurationReport } from './definitions/wagon-status-duration.report'; -import { wagonRequestsReport } from './definitions/wagon-requests.report'; -import { locomotiveFleetStatusReport } from './definitions/locomotive-fleet-status.report'; -import { bookingStatusBreakdownReport } from './definitions/booking-status-breakdown.report'; -import { trainScheduleStatusReport } from './definitions/train-schedule-status.report'; -import { trainTurnaroundReport } from './definitions/train-turnaround.report'; -import { wagonTeuUtilizationReport } from './definitions/wagon-teu-utilization.report'; -import { loadedCapacityReport } from './definitions/loaded-capacity.report'; -import { globalLogisticsWagonsReport } from './definitions/global-logistics-wagons.report'; -import { customerStatusReport } from './definitions/customer-status.report'; -import { contractLifecycleReport } from './definitions/contract-lifecycle.report'; -import { customsDocumentsReport } from './definitions/customs-documents.report'; -import { invoicingPipelineReport } from './definitions/invoicing-pipeline.report'; -import { firstLastMileBookingsReport } from './definitions/first-last-mile-bookings.report'; -import { invoicesByStatusReport } from './definitions/invoices-by-status.report'; -import { paymentsByStatusReport } from './definitions/payments-by-status.report'; -import { revenueSummaryReport } from './definitions/revenue-summary.report'; -import { cargoSummaryReport } from './definitions/cargo-summary.report'; -import { revenueByCategoryReport } from './definitions/revenue-by-category.report'; -import { revenueTransactionsReport } from './definitions/revenue-transactions.report'; -import { revenueByPeriodReport } from './definitions/revenue-by-period.report'; -import { revenueByRouteReport } from './definitions/revenue-by-route.report'; -import { revenueTopCustomersReport } from './definitions/revenue-top-customers.report'; -import { paymentClassificationReport } from './definitions/payment-classification.report'; -import { revenueReconciliationReport } from './definitions/revenue-reconciliation.report'; -import { receivablesPayablesReport } from './definitions/receivables-payables.report'; -import { revenueAnomaliesReport } from './definitions/revenue-anomalies.report'; -import { stationStayingTimeReport } from './definitions/station-staying-time.report'; -import { turnaroundCycleReport } from './definitions/turnaround-cycle.report'; -import { trainDelaysReport } from './definitions/train-delays.report'; -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'; +import { ReportKey } from "../../seed/freight-permissions.registry"; +import { revenueByCustomerReport } from "./definitions/revenue-by-customer.report"; +import { agingReceivablesReport } from "./definitions/aging-receivables.report"; +import { contractUtilizationReport } from "./definitions/contract-utilization.report"; +import { wagonFleetStatusReport } from "./definitions/wagon-fleet-status.report"; +import { wagonStatusDurationReport } from "./definitions/wagon-status-duration.report"; +import { wagonRequestsReport } from "./definitions/wagon-requests.report"; +import { locomotiveFleetStatusReport } from "./definitions/locomotive-fleet-status.report"; +import { bookingStatusBreakdownReport } from "./definitions/booking-status-breakdown.report"; +import { trainScheduleStatusReport } from "./definitions/train-schedule-status.report"; +import { trainTurnaroundReport } from "./definitions/train-turnaround.report"; +import { wagonTeuUtilizationReport } from "./definitions/wagon-teu-utilization.report"; +import { loadedCapacityReport } from "./definitions/loaded-capacity.report"; +import { globalLogisticsWagonsReport } from "./definitions/global-logistics-wagons.report"; +import { customsDocumentsReport } from "./definitions/customs-documents.report"; +import { invoicingPipelineReport } from "./definitions/invoicing-pipeline.report"; +import { firstLastMileBookingsReport } from "./definitions/first-last-mile-bookings.report"; +import { cargoSummaryReport } from "./definitions/cargo-summary.report"; +import { revenueByCategoryReport } from "./definitions/revenue-by-category.report"; +import { revenueTransactionsReport } from "./definitions/revenue-transactions.report"; +import { revenueByPeriodReport } from "./definitions/revenue-by-period.report"; +import { revenueByRouteReport } from "./definitions/revenue-by-route.report"; +import { revenueTopCustomersReport } from "./definitions/revenue-top-customers.report"; +import { paymentClassificationReport } from "./definitions/payment-classification.report"; +import { revenueReconciliationReport } from "./definitions/revenue-reconciliation.report"; +import { receivablesPayablesReport } from "./definitions/receivables-payables.report"; +import { revenueAnomaliesReport } from "./definitions/revenue-anomalies.report"; +import { stationStayingTimeReport } from "./definitions/station-staying-time.report"; +import { turnaroundCycleReport } from "./definitions/turnaround-cycle.report"; +import { trainDelaysReport } from "./definitions/train-delays.report"; +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 @@ -47,7 +41,6 @@ import { ReportDefinition } from './report.types'; * an entry here. Nothing else — no frontend edit, no route, no sidebar edit. */ export const REPORTS: ReportDefinition[] = [ - bookingsListReport, revenueByCustomerReport, agingReceivablesReport, contractUtilizationReport, @@ -61,14 +54,9 @@ export const REPORTS: ReportDefinition[] = [ wagonTeuUtilizationReport, loadedCapacityReport, globalLogisticsWagonsReport, - customerStatusReport, - contractLifecycleReport, customsDocumentsReport, invoicingPipelineReport, firstLastMileBookingsReport, - invoicesByStatusReport, - paymentsByStatusReport, - revenueSummaryReport, cargoSummaryReport, revenueByCategoryReport, revenueTransactionsReport, @@ -89,7 +77,9 @@ export const REPORTS: ReportDefinition[] = [ cargoVolumeByStationReport, ]; -const BY_KEY = new Map(REPORTS.map((r) => [r.key, r])); +const BY_KEY = new Map( + REPORTS.map((r) => [r.key, r]), +); export function getReport(key: string): ReportDefinition | undefined { return BY_KEY.get(key as ReportKey); diff --git a/apps/edr-freight-api/src/modules/reports/revenue-classification.spec.ts b/apps/edr-freight-api/src/modules/reports/revenue-classification.spec.ts index 42475d591..abc63fc04 100644 --- a/apps/edr-freight-api/src/modules/reports/revenue-classification.spec.ts +++ b/apps/edr-freight-api/src/modules/reports/revenue-classification.spec.ts @@ -86,17 +86,54 @@ describe('revenue classification', () => { expect(periodExpr({ period: 'quarter' })).toContain("date_trunc('quarter'"); expect(periodExpr({ period: 'year' })).toContain("date_trunc('year'"); // Anything unrecognised — including an injection attempt — becomes 'month'. - expect(periodExpr({ period: "day'); DROP TABLE freight.invoices; --" })).toContain( - "date_trunc('month'", - ); + const injection = "day'); DROP TABLE freight.invoices; --"; + expect(periodExpr({ period: injection })).toContain("date_trunc('month'"); + expect(periodExpr({ period: injection })).not.toContain('DROP TABLE'); expect(periodExpr({})).toContain("date_trunc('month'"); }); it('offers exactly the period units the expression understands', () => { const offered = (PERIOD_FILTER.options ?? []).map((o) => o.value); - expect(offered.length).toBe(5); - for (const unit of offered) { - expect(periodExpr({ period: unit })).toContain(`date_trunc('${unit}'`); + expect(offered).toEqual([ + 'day', + '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)'); }); }); diff --git a/apps/edr-freight-api/src/modules/reports/revenue-classification.ts b/apps/edr-freight-api/src/modules/reports/revenue-classification.ts index 526e35add..e3ba0a65d 100644 --- a/apps/edr-freight-api/src/modules/reports/revenue-classification.ts +++ b/apps/edr-freight-api/src/modules/reports/revenue-classification.ts @@ -139,8 +139,15 @@ const labelCase = (expr: string, options: ReportFilterOption[]): string => .map((o) => `WHEN '${o.value}' THEN '${o.label.replace(/'/g, "''")}'`) .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. */ -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. @@ -223,22 +230,103 @@ END`; // --------------------------------------------------------------------------- /** - * 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 five compile-time constants does. + * A granularity, as SQL builders rather than fragments to interpolate. * - * Every format is zero-padded, so lexicographic order equals chronological - * order. The growth window depends on that. + * Five of the eight are plain `date_trunc` units. The other three — half-year, + * 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 = { - day: { trunc: 'day', fmt: 'YYYY-MM-DD', label: 'Daily', step: '1 day' }, - week: { trunc: 'week', fmt: 'IYYY-"W"IW', label: 'Weekly', step: '1 week' }, - month: { trunc: 'month', fmt: 'YYYY-MM', label: 'Monthly', step: '1 month' }, +interface PeriodUnit { + label: string; + /** Interval one whole block wide. Only exact for the six regular units. */ + 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 Jan–Sep plus + * a short Oct–Dec. 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 = { + 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 — // 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' }, - year: { trunc: 'year', fmt: 'YYYY', label: 'Yearly', step: '1 year' }, -} as const; + quarter: regular('quarter', 'YYYY-"Q"Q', 'Quarterly', '3 months'), + half_year: monthBlocks(6, 'H', 'Half-yearly'), + 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 = { key: 'period', @@ -267,10 +355,8 @@ export function periodExpr(params: Record): string { return periodExprOn(REVENUE_DATE, params); } -export function resolvePeriod( - params: Record, -): (typeof PERIOD_UNITS)[keyof typeof PERIOD_UNITS] { - const key = String(params.period ?? '') as keyof typeof PERIOD_UNITS; +export function resolvePeriod(params: Record): PeriodUnit { + const key = String(params.period ?? ''); 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. */ export const periodExprOn = (dateExpr: string, params: Record): string => - `to_char(${periodTruncExprOn(dateExpr, params)}, '${resolvePeriod(params).fmt}')`; + resolvePeriod(params).labelOn(periodTruncExprOn(dateExpr, params)); export const periodTruncExprOn = (dateExpr: string, params: Record): 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. */ export const periodTruncExpr = (params: Record): string => @@ -298,9 +384,16 @@ export const periodTruncExpr = (params: Record): string => export const periodOrdinalExpr = (params: Record): string => `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 => - `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 diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts index 56459870c..103f4c2b7 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts @@ -17,8 +17,9 @@ export type WagonAdjustmentAction = (typeof WAGON_ADJUSTMENT_ACTIONS)[number]; @Index(['trainScheduleId']) @Index(['trainId']) export class ScheduleWagonAdjustmentLog extends BaseEntity { - @Column({ name: 'train_schedule_id', type: 'uuid' }) - trainScheduleId!: string; + /** Null when the change was made from the train builder with no live schedule. */ + @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true }) + trainScheduleId!: string | null; @Column({ name: 'train_id', type: 'uuid' }) trainId!: string; diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index b3cb3e821..fdd76861d 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -130,6 +130,33 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'planned_wagon_yards', type: 'jsonb', nullable: true }) plannedWagonYards?: Record | 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 | 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 | 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`. */ @Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' }) bookingWindowStatus!: string; diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts index b0cbeb886..f4f25060b 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -18,6 +18,30 @@ export class TrainSchedulesRepository extends BaseRepository { 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 { + 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 { return this.repo(manager).findOne({ where: { id }, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index ce0e77a3e..1c6b66ea8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -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. 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); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 0a7d87498..3469413ca 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -10,6 +10,7 @@ import { Optional, } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { SchedulerRegistry } from '@nestjs/schedule'; import { Between, @@ -92,13 +93,16 @@ import { BookingWindowGateway } from './booking-window.gateway'; import { MAX_TEU_SLOTS_PER_WAGON, containerWagonsForLines, + roundTons, } from './utils/wagon-plan.util'; import { Capacity, CorridorBudget, CorridorLeg, OverageTolerance, + addCoupledWagons, stopYardsFor, + subtractCutWagons, } from './corridor-capacity.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 splitService?: BookingSplitService, + // Optional so hand-constructed spec instances keep compiling. + @Optional() private readonly eventEmitter?: EventEmitter2, @Optional() @Inject(forwardRef(() => RemainderPlacementService)) private readonly remainderPlacement?: RemainderPlacementService, @@ -1484,6 +1490,44 @@ export class BookingBatchService implements OnModuleInit { "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); }); @@ -2275,6 +2319,7 @@ export class BookingBatchService implements OnModuleInit { await this.recomputeBulkPriorities(pool, wagonDims); this.resortPoolByPriority(pool, await this.windowCycleIndexer(schedule)); const units = this.groupConsolidatedPool(pool); + const scarcityRank = this.scarcityRankForPool(pool, allowedWagonTypes); let armed = false; let preempted = false; let reservedThisPass = 0; @@ -2301,17 +2346,34 @@ export class BookingBatchService implements OnModuleInit { const leg = budget.legForYards(booking.originYardId, booking.destinationYardId); const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes); // Abstract room AND real wagons of a type this booking can ride — see - // fillRouteDayInternal for why both gates are needed. - const stocked = this.hasWagonStock(stock, wagonTypeIds, need.wagons, leg); + // fillRouteDayInternal for why both gates are needed. PER_TON bulk + // 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. this.logger.debug( - `[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(need)} ` + - `roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)} ` + - `stocked=${stocked}`, + `[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify( + smart?.need ?? need, + )} roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} admitted=${admitted}`, ); - if (!budget.fits(need, leg) || !stocked) { + if (!admitted) { if (isGov) { const freed = await this.preemptForGovernment( scheduleId, @@ -2355,9 +2417,16 @@ export class BookingBatchService implements OnModuleInit { armed = true; 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. - 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; } catch (err) { this.logger.error( @@ -2532,6 +2601,10 @@ export class BookingBatchService implements OnModuleInit { // Consolidated partners collapse into one atomic unit (both-or-neither); a // consolidated booking whose partner isn't ready this cycle is skipped. 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. 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. 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 // 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 // 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); - return ( - leg != null && + if (leg == null) continue; + 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) && this.hasWagonStock(t.stock, wagonTypeIds, need.wagons, leg) - ); - }); + ) { + target = t; + break; + } + } // Per-unit trace: chosen train + each train's remaining room on this leg. this.logger.debug( @@ -2645,10 +2754,18 @@ export class BookingBatchService implements OnModuleInit { target.armed = true; 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 - // gone — otherwise two bookings both "fit" the same 16 NW5. - target.stock.consume(wagonTypeIds, need.wagons, legOn(target)!); + // gone — otherwise two bookings both "fit" the same 16 NW5. The smart + // 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; reservedThisPass += 1; } catch (err) { @@ -2724,6 +2841,21 @@ export class BookingBatchService implements OnModuleInit { wagonTypeIds: string[] = [], ): Promise { 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 .map((c) => { 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 // other 4 leave as the usual remainder booking, instead of paying for // 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 ? c.stock?.availableFor(wagonTypeIds, leg) : undefined; const wagons = 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 => x != null && x.room.wagons >= 1) .sort((a, b) => b.room.wagons - a.room.wagons)[0]; @@ -2749,10 +2912,15 @@ export class BookingBatchService implements OnModuleInit { target.c.id, target.room, need, + target.seat, ); if (!offered) return false; 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; return true; } @@ -2767,6 +2935,12 @@ export class BookingBatchService implements OnModuleInit { scheduleId: string, budget: 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 { if (!this.splitService) return null; // 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 // validates against. Bulk splits ride FULL wagons only: the offer never // part-loads its last wagon. - const perWagon = this.dimsFor(booking, wagonDims); - const partial = sizePartialOfferWagons(budget, need.wagons, perWagon, { + const perWagon = seat?.perWagon ?? this.dimsFor(booking, wagonDims); + // 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", }); if (!partial) return null; @@ -2793,7 +2973,7 @@ export class BookingBatchService implements OnModuleInit { const sized = await this.splitService.sizeOffer( booking, partial.wagons, - need.wagons, + wholeWagons, perWagon.capacityTons, partial.maxCargoTons, ); @@ -2832,8 +3012,9 @@ export class BookingBatchService implements OnModuleInit { * Settle a schedule's reserved bookings. `expireUnpaidUnknownDeadline` decides * how to treat a reservation with no deadline (durable path: leave it; timeout * path: expire it). Consolidated pairs settle atomically: both allocate only - * when both paid; if either partner expires, both expire (a half-paid shared - * wagon must not ship). Returns whether anything changed. + * when both paid; when neither paid, both expire. A half-paid pair splits: + * 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( scheduleId: string, @@ -2876,8 +3057,10 @@ export class BookingBatchService implements OnModuleInit { await this.allocate(scheduleId, partner, "paid"); anySettled = true; } 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(partner); anySettled = true; } continue; @@ -3816,6 +3999,55 @@ export class BookingBatchService implements OnModuleInit { booking: Booking, reason: "payment" | "no-capacity" = "payment", ): Promise { + // 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) { const fresh = await this.dataSource .getRepository(Booking) @@ -4097,7 +4329,50 @@ export class BookingBatchService implements OnModuleInit { // and push once per schedule after the sweep (most unaccepted rows are // unpinned under day-level pooling, so this usually emits nothing). const touchedScheduleIds = new Set(); + const swept = new Set(); 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); await this.bookingsRepository.update(booking.id, { status: "EXPIRED", @@ -4790,6 +5065,8 @@ export class BookingBatchService implements OnModuleInit { stock.byYardId, 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 // same bookings the corridor budget subtracted. A booking with no resolvable // wagon type still occupies steel, so it drains any type at its yard. @@ -4798,12 +5075,34 @@ export class BookingBatchService implements OnModuleInit { this.loadAllowedWagonTypeIds(), ]); 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 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( typeIds.length ? typeIds : anyType, this.wagonsFor(b, wagonDims), - budget.legForYards(b.originYardId, b.destinationYardId), + leg, ); } return ledger; @@ -4825,6 +5124,122 @@ export class BookingBatchService implements OnModuleInit { 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; + byContainerTypeId: Map; + }, + ): Map { + const groups = new Map(); + 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(); + 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, + /** + * 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: { byCargoTypeId: Map; byContainerTypeId: Map; @@ -4969,6 +5384,12 @@ export class BookingBatchService implements OnModuleInit { // wagon serves disjoint legs — capacity freed past an alight yard is real. const stops = await this.stopsForSchedule(schedule); 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)) { budget.subtract( this.needFor(b, wagonDims), diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.smart-need.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.smart-need.spec.ts new file mode 100644 index 000000000..c37003271 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.smart-need.spec.ts @@ -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, + ) => + ( + service as unknown as { + smartBulkNeed: ( + b: Booking, + d: unknown, + s: WagonStockLedger, + l: { fromEdge: number; toEdge: number }, + r: Map, + 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, + 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 }]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.spec.ts index f51199b8c..a97ceebe5 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.spec.ts @@ -1,6 +1,7 @@ import { autoFillPlacements, findMissingContainerNumberIssues, + occupiedTeuPerEdgeBySlot, type ContainerUnitForPlacement, } from './container-placement.util'; @@ -27,14 +28,15 @@ describe('container-placement.util', () => { ]; it('auto-fills placements across slots', () => { - const placements = autoFillPlacements(units, [1, 2]); + const { placements, overflow } = autoFillPlacements(units, [1, 2]); expect(placements).toHaveLength(2); + expect(overflow).toHaveLength(0); expect(placements[0].sequenceNo).toBe(1); expect(placements[1].sequenceNo).toBe(2); }); 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); expect(issues).toHaveLength(0); expect(placements[1].containerNumber).toMatch(/^TBD-/); @@ -53,7 +55,84 @@ describe('container-placement.util', () => { containerNumber: null, }, ]; - const placements = autoFillPlacements(single, [1]); + const { placements } = autoFillPlacements(single, [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); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.ts index a0f709cdf..e9eed6a05 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.ts @@ -9,6 +9,18 @@ export type ContainerUnitForPlacement = { teuSlots?: number; sizeFt?: number; 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 { @@ -25,51 +37,118 @@ export function resolveContainerNumber(unit: ContainerUnitForPlacement): string 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( units: ContainerUnitForPlacement[], - containerSlots: number[], + containerSlots: ReadonlyArray, /** * 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 - * 40ft onto a wagon another booking's placement had already filled. + * A plain number occupies every edge of the slot; an array is per-edge. */ - occupiedTeuBySlot: ReadonlyMap = new Map(), -): ContainerPlacementInput[] { - if (!units.length || !containerSlots.length) return []; - + occupiedTeuBySlot: ReadonlyMap = new Map(), + edgeCount = 1, +): { 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 overflow: ContainerUnitForPlacement[] = []; + if (!units.length) return { placements, overflow }; + if (!slots.length) return { placements, overflow: [...units] }; + const MAX_TEU_PER_WAGON = 2; - let currentSlotIndex = 0; - let teuInCurrentSlot = occupiedTeuBySlot.get(containerSlots[0]!) ?? 0; + const used = new Map(); + const usedRow = (sequenceNo: number): number[] => { + let row = used.get(sequenceNo); + if (!row) { + const seed = occupiedTeuBySlot.get(sequenceNo) ?? 0; + row = + typeof seed === 'number' + ? new Array(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) { const teu = unit.teuSlots ?? (unit.sizeFt && unit.sizeFt >= 40 ? 2 : 1); - - while ( - teuInCurrentSlot > 0 && - teuInCurrentSlot + teu > MAX_TEU_PER_WAGON && - currentSlotIndex < containerSlots.length - 1 - ) { - currentSlotIndex += 1; - teuInCurrentSlot = occupiedTeuBySlot.get(containerSlots[currentSlotIndex]!) ?? 0; + const leg = legOf(unit); + const slot = slots.find((s) => { + if (s.from > leg.from || leg.to > s.to) return false; + const row = usedRow(s.sequenceNo); + for (let e = leg.from; e < leg.to; e += 1) { + if ((row[e] ?? 0) + teu > MAX_TEU_PER_WAGON) return false; + } + return true; + }); + if (!slot) { + overflow.push(unit); + continue; } - - const sequenceNo = - containerSlots[Math.min(currentSlotIndex, containerSlots.length - 1)] ?? - containerSlots[containerSlots.length - 1] ?? - containerSlots[0]; - + const row = usedRow(slot.sequenceNo); + for (let e = leg.from; e < leg.to; e += 1) row[e] = (row[e] ?? 0) + teu; placements.push({ bookingContainerId: unit.bookingContainerId, unitIndex: unit.unitIndex, - sequenceNo, + sequenceNo: slot.sequenceNo, 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 { + const edges = Math.max(1, edgeCount); + const unitByKey = new Map(units.map((u) => [`${u.bookingContainerId}:${u.unitIndex}`, u])); + const out = new Map(); + 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(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. */ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index 834b3d174..e9a747784 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -1,6 +1,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import type { Response } from "express"; 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 { resolveAuthUserId } from "../../../common/resolve-auth-user-id"; @@ -16,7 +17,9 @@ import { TrainSchedulingCancel, TrainSchedulingCreate, TrainSchedulingEditTrainNumber, + TrainSchedulingLoad, TrainSchedulingReschedule, + TrainSchedulingUnload, TrainSchedulingRulesManage, TrainSchedulingUpdate, TrainSchedulingView, @@ -206,7 +209,7 @@ export class TrainSchedulingController { @TrainSchedulingView() @ApiOperation({ 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) { return this.trainSchedulingService.getScheduleWagonYards(id); @@ -216,13 +219,13 @@ export class TrainSchedulingController { @TrainSchedulingUpdate() @ApiOperation({ 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( @Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateScheduleWagonYardsDto, ) { - return this.trainSchedulingService.updateScheduleWagonYards(id, dto.moves); + return this.trainSchedulingService.updateScheduleWagonYards(id, dto); } @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") @TrainSchedulingView() @ApiOperation({ 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", }) - getScheduleHistory(@Param("id", ParseUUIDPipe) id: string) { - return this.trainSchedulingService.getScheduleHistory(id); + getScheduleHistory( + @Param("id", ParseUUIDPipe) id: string, + @Query() query: PaginationQueryDto, + ) { + return this.trainSchedulingService.getScheduleHistory(id, query); } @Get("bookable-schedules") @@ -598,7 +614,7 @@ export class TrainSchedulingController { } @Post("schedules/:id/bookings/:bookingId/load") - @TrainSchedulingUpdate() + @TrainSchedulingLoad() @ApiOperation({ summary: "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") - @TrainSchedulingUpdate() + @TrainSchedulingUnload() @ApiOperation({ summary: "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") - @TrainSchedulingUpdate() + @TrainSchedulingLoad() @ApiOperation({ 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") - @TrainSchedulingUpdate() + @TrainSchedulingUnload() @ApiOperation({ summary: "Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)", diff --git a/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.spec.ts index 15d065d09..fafc13a2c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.spec.ts @@ -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'; 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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts index f2602a157..afb8367f8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts @@ -48,9 +48,38 @@ export function capacityFits(need: Capacity, budget: Capacity): boolean { } /** - * Ordered stop yard ids for a schedule. Route milestones (already ordered by - * sequence) when there are at least two; otherwise the schedule's own - * origin/destination pair — the legacy two-stop pseudo-route. + * Orient a milestone-derived stop list to THIS schedule's endpoints. + * + * 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( milestoneYardIdsInOrder: string[] | null | undefined, @@ -58,9 +87,60 @@ export function stopYardsFor( destinationStationId: string, ): string[] { 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 | 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 | 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. */ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-wagon-yards.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-wagon-yards.dto.ts index fa4b5e4c7..127f2dc0f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-wagon-yards.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-wagon-yards.dto.ts @@ -1,13 +1,57 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; 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 { @ApiProperty({ format: 'uuid', description: "Wagon coupled to the schedule's built train." }) @IsUUID() 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() yardId!: string; } @@ -18,9 +62,32 @@ export class UpdateScheduleWagonYardsDto { description: 'Wagon → planned boarding yard for THIS schedule only. Physical wagon yards are untouched; dispatch requires both to agree.', }) + @IsOptional() @IsArray() @ArrayMaxSize(500) @ValidateNested({ each: true }) @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[]; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/edge-load.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/edge-load.util.spec.ts new file mode 100644 index 000000000..43dfaa95d --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/edge-load.util.spec.ts @@ -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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/edge-load.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/edge-load.util.ts new file mode 100644 index 000000000..45e46360f --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/edge-load.util.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 6f758f053..5ac6ed0bb 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -130,13 +130,20 @@ import { maxEdgeConsistUsage, perEdgeConsistUsage, validateContainerPlacements, + validateWagonCargoExclusivity, validateMixedTrainLimitsPerEdge, MAX_TEU_SLOTS_PER_WAGON, type ContainerPlacementInput, type WagonPlanSlot, } from '../utils/wagon-plan.util'; -import { CorridorBudget } from '../corridor-capacity.util'; +import { + addCoupledWagons, + CorridorBudget, + orientStopsToSchedule, + subtractCutWagons, +} from '../corridor-capacity.util'; import { deriveScheduleDirection } from '../utils/derive-schedule-direction.util'; +import { computeEdgeLoads } from '../edge-load.util'; import { computeScheduleWagonUsage } from '../utils/schedule-wagon-usage.util'; import { defaultPlannedWagonYards, @@ -199,7 +206,7 @@ import { isPlaceholderContainerNumber, placementsForBookings, type ContainerUnitForPlacement, - occupiedTeuBySlot, + occupiedTeuPerEdgeBySlot, } from '../container-placement.util'; const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const; @@ -1986,22 +1993,89 @@ export class TrainSchedulingService { (b) => b.freightType === 'CONTAINER', ); if (containerBookings.length) { - const units = expandBookingContainerUnits(containerBookings); + // Leg-aware fill: each unit carries its booking's stop-index span and + // each slot the span it rides, so TEU is counted per corridor edge — + // the same accounting the planner and the placement validator use. A + // whole-route walk on a leg-sharing train believed every wagon was + // full after one 40ft on ANY leg and dumped the leftovers onto the + // last wagon (#42), producing a wall of per-unit violations. + const previewStops = await this.stopYardsForSchedule(schedule); + const edgeCount = Math.max(1, previewStops.length - 1); + const legOfBooking = new Map( + preview.bookings.map((b) => { + const from = previewStops.indexOf(b.originYardId); + const to = previewStops.indexOf(b.destinationYardId); + return [ + b.id, + from >= 0 && to > from ? { from, to } : { from: 0, to: edgeCount }, + ] as const; + }), + ); + const units = expandBookingContainerUnits(containerBookings).map((u) => ({ + ...u, + leg: legOfBooking.get(u.bookingId), + })); + const slotSpans = preview.wagonPlan + .filter( + (s) => + s.slotLoadType === 'CONTAINER' || + s.allocations.some((a) => a.loadType === AllocationLoadType.Container), + ) + .map((s) => { + const from = s.boardYardId ? previewStops.indexOf(s.boardYardId) : 0; + const toIdx = s.alightYardId ? previewStops.indexOf(s.alightYardId) : -1; + return { + sequenceNo: s.sequenceNo, + from: from >= 0 ? from : 0, + to: toIdx > 0 ? toIdx : edgeCount, + }; + }); + // Caller-provided placements can be STALE: the workspace pins container + // positions against the plan it last fetched, and every (re)assignment + // rebuilds the plan with fresh sequence numbers (remove + re-add being + // the common case). A pin pointing at a slot that no longer exists must + // not poison the fill — drop it and auto-place its unit instead; the + // placement validator still checks whatever survives. + const validSeq = new Set(slotSpans.map((s) => s.sequenceNo)); + const provided = (containerPlacements ?? []).filter((p) => + validSeq.has(p.sequenceNo), + ); + const droppedStale = (containerPlacements ?? []).length - provided.length; + if (droppedStale > 0) { + this.logger.warn( + `[assign ${scheduleId}] dropped ${droppedStale} stale container placement(s) ` + + `pointing at slots not in the rebuilt plan — re-auto-filling those units`, + ); + } const providedKeys = new Set( - (containerPlacements ?? []).map( - (p) => `${p.bookingContainerId}:${p.unitIndex}`, - ), + provided.map((p) => `${p.bookingContainerId}:${p.unitIndex}`), ); const unplacedUnits = units.filter( (u) => !providedKeys.has(`${u.bookingContainerId}:${u.unitIndex}`), ); - if (unplacedUnits.length) { - const slots = getContainerSlotSequenceNos(preview.wagonPlan); - const generated = autoFillPlacements( + if (unplacedUnits.length || droppedStale > 0) { + const { placements: generated, overflow } = autoFillPlacements( unplacedUnits, - slots, - occupiedTeuBySlot(containerPlacements ?? [], units), + slotSpans, + occupiedTeuPerEdgeBySlot(provided, units, edgeCount), + edgeCount, ); + if (overflow.length) { + // One honest message, grouped per booking — not one violation per + // container piled onto the same wagon. + const byRef = new Map(); + for (const u of overflow) { + const ref = u.bookingReference ?? u.bookingId; + byRef.set(ref, (byRef.get(ref) ?? 0) + 1); + } + const detail = [...byRef.entries()] + .map(([ref, n]) => `${ref}: ${n} container(s) have no wagon space left`) + .join('; '); + throw new BadRequestException({ + message: `Booking validation failed: ${detail} — the train's container wagons are full on the booking's leg`, + violations: [detail], + }); + } const missing = findMissingContainerNumberIssues(unplacedUnits, generated); if (missing.length) { throw new BadRequestException({ @@ -2011,7 +2085,7 @@ export class TrainSchedulingService { violations: missing.map((m) => m.issue), }); } - containerPlacements = [...(containerPlacements ?? []), ...generated]; + containerPlacements = [...provided, ...generated]; } } } @@ -2127,7 +2201,16 @@ export class TrainSchedulingService { // so their tare rides on top of the binding edge. const emptyConsistTareTons = Math.max(0, consistTareTons - planTareTons); const scheduleStops = await this.stopYardsForSchedule(schedule); - const perEdge = perEdgeConsistUsage(wagonPlan, scheduleStops); + // Same leg map the validator used: cargo weighs only the edges its booking + // rides, so a Dire Dawa boarder never inflates the Djibouti leg. + const commitLegByBookingId = new Map( + bookings.flatMap((b) => { + const from = scheduleStops.indexOf(b.originYardId); + const to = scheduleStops.indexOf(b.destinationYardId); + return from >= 0 && to > from ? [[b.id, { from, to }] as const] : []; + }), + ); + const perEdge = perEdgeConsistUsage(wagonPlan, scheduleStops, commitLegByBookingId); const stopLabels = await this.yardLabelMap(scheduleStops); // Each edge is its own consist — name EVERY leg that breaks the limit, // not just the heaviest figure, so staff see where along A→…→E it fails. @@ -2200,12 +2283,23 @@ export class TrainSchedulingService { ].map((bookingId) => ({ trainScheduleId: scheduleId, bookingId })); await this.trainScheduleBookingsRepository.createMany(scheduleBookingRecords, manager); + // Leg spans for the per-edge exclusivity guard — a wagon may carry + // containers to Dire Dawa and bulk onward, never both at once. + const persistLegs = new Map( + bookings.flatMap((b) => { + const from = scheduleStops.indexOf(b.originYardId); + const to = scheduleStops.indexOf(b.destinationYardId); + return from >= 0 && to > from ? [[b.id, { from, to }] as const] : []; + }), + ); await this.persistAllocationsAndLoads( manager, savedWagons, wagonPlan, bookings, containerPlacements ?? [], + persistLegs, + Math.max(1, scheduleStops.length - 1), ); // The link above puts these bookings on the train: they are SCHEDULED, not @@ -2819,6 +2913,56 @@ export class TrainSchedulingService { { status: WagonStatus.Assigned, currentTrainScheduleId: scheduleId }, ); } + // Planned couples boarding at the ORIGIN join the built train now — the + // departure is the moment they are physically hooked on. Mid-route + // couples join at their stop's checkpoint log instead. + const dispatchTrainId = schedule.trainSet?.trainId ?? null; + const originCouples = Object.entries(schedule.plannedWagonCouples ?? {}).filter( + ([, yardId]) => yardId === schedule.originStationId, + ); + if (originCouples.length && dispatchTrainId) { + const consist = await manager.getRepository(Wagon).find({ + where: { trainId: dispatchTrainId }, + select: { id: true, sequenceNumber: true }, + }); + let maxSeq = consist.reduce((m, w) => Math.max(m, w.sequenceNumber ?? 0), 0); + for (const [coupleWagonId, coupleYardId] of originCouples) { + const wagon = await manager + .getRepository(Wagon) + .findOne({ where: { id: coupleWagonId }, lock: { mode: 'pessimistic_write' } }); + if (!wagon) continue; + if (wagon.trainId === dispatchTrainId) continue; // already joined — self-heal + if ( + wagon.trainId || + wagon.status !== WagonStatus.Available || + wagon.currentTrainScheduleId || + wagon.currentYardId !== coupleYardId + ) { + throw new ConflictException( + `Wagon ${wagon.wagonNumber} is planned to couple at dispatch but is no longer free at the origin yard — remove the couple in the Schedule yards tab or free the wagon`, + ); + } + maxSeq += 1; + await manager.getRepository(Wagon).update(wagon.id, { + trainId: dispatchTrainId, + sequenceNumber: maxSeq, + status: WagonStatus.Assigned, + currentTrainScheduleId: scheduleId, + }); + await manager.getRepository(ScheduleWagonAdjustmentLog).save( + manager.getRepository(ScheduleWagonAdjustmentLog).create({ + trainScheduleId: scheduleId, + trainId: dispatchTrainId, + action: 'ADD', + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + adjustedByUserId: null, + yardId: coupleYardId, + occurredAt: now, + }), + ); + } + } for (const sb of schedule.scheduleBookings ?? []) { await this.bookingsRepository.updateSchedulingFields( sb.bookingId, @@ -4235,7 +4379,10 @@ export class TrainSchedulingService { /** Log the train passing a station. Logging the destination station triggers arrival. */ async recordCheckpoint(scheduleId: string, dto: RecordCheckpointDto) { - const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + // Slim graph: checkpoint logging reads stops, locomotives, the built + // train and the wagon plans — never the booking/container branches. + // (arriveSchedule, invoked on the final leg, loads its own full graph.) + const schedule = await this.trainSchedulesRepository.findByIdWithConsistLite(scheduleId); if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); } @@ -4316,6 +4463,166 @@ export class TrainSchedulingService { const passedYardIds = stations .filter((s) => s.sequenceNo <= dto.sequenceNo) .map((s) => s.yardId); + // Wagons planned to CUT at a stop the train has now passed detach + // here: position freezes at the cut yard and they stop riding the + // position fix below (its filter is current_train_schedule_id). + // Cargo-carrying ones were already settled by autoUnloadAtYard above + // (validation forbids cargo booked past the cut) — anything still + // bound to the schedule is riding empty. Matching against ALL passed + // yards, not just this one, self-heals skipped checkpoint logs. + const cutPlan = schedule.plannedWagonCutYards ?? {}; + const realCutIds = new Set(schedule.plannedWagonRealCuts ?? []); + const builtTrainId = schedule.trainSet?.trainId ?? null; + const cutNow = Object.entries(cutPlan).filter(([, yardId]) => + passedYardIds.includes(yardId), + ); + // One fetch for the whole plan, one bulk insert per log table — the + // per-wagon UPDATEs stay (each patch differs) but the transaction no + // longer serializes a findOne + save pair per wagon. + const cutWagonById = new Map( + cutNow.length + ? ( + await manager + .getRepository(Wagon) + .find({ where: { id: In(cutNow.map(([wagonId]) => wagonId)) } }) + ).map((w) => [w.id, w]) + : [], + ); + const adjustmentRows: ScheduleWagonAdjustmentLog[] = []; + const movementRows: WagonMovement[] = []; + let realCutHappened = false; + for (const [wagonId, cutYardId] of cutNow) { + const wagon = cutWagonById.get(wagonId); + // Already settled earlier (or re-pinned elsewhere) — not ours to move. + if (!wagon || wagon.currentTrainScheduleId !== scheduleId) continue; + if (realCutIds.has(wagonId) && builtTrainId) { + // REAL cut: the built train permanently loses the wagon here. + await manager.getRepository(Wagon).update(wagonId, { + currentYardId: cutYardId, + currentTrainScheduleId: null, + trainSetWagonId: null, + trainId: null, + sequenceNumber: null, + importTrainNumber: null, + exportTrainNumber: null, + status: WagonStatus.Available, + }); + // Any slot of this train's schedules still pinned to it is stale. + await manager.query( + `UPDATE freight.train_set_wagons SET physical_wagon_id = NULL + WHERE physical_wagon_id = $1 + AND train_set_id IN (SELECT id FROM freight.train_sets WHERE train_id = $2)`, + [wagonId, builtTrainId], + ); + adjustmentRows.push( + manager.getRepository(ScheduleWagonAdjustmentLog).create({ + trainScheduleId: scheduleId, + trainId: builtTrainId, + action: 'REMOVE', + wagonId, + wagonNumber: wagon.wagonNumber, + adjustedByUserId: null, + yardId: cutYardId, + occurredAt, + }), + ); + realCutHappened = true; + } else { + // Soft cut: sits out the rest of this trip, stays in the build. + await manager.getRepository(Wagon).update(wagonId, { + currentYardId: cutYardId, + currentTrainScheduleId: null, + trainSetWagonId: null, + status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, + }); + } + movementRows.push( + manager.getRepository(WagonMovement).create({ + wagonId, + fromYardId: scheduleYardOf(schedule.plannedWagonYards, wagon) ?? schedule.originStationId, + toYardId: cutYardId, + trainScheduleId: scheduleId, + kind: WagonMovementKind.EmptyReposition, + occurredAt, + }), + ); + } + if (adjustmentRows.length) { + await manager.getRepository(ScheduleWagonAdjustmentLog).save(adjustmentRows); + } + if (movementRows.length) { + await manager.getRepository(WagonMovement).save(movementRows); + } + // Keep the coupling order gapless after permanent removals. + if (realCutHappened && builtTrainId) { + const remaining = await manager.getRepository(Wagon).find({ + where: { trainId: builtTrainId }, + order: { sequenceNumber: 'ASC' }, + }); + for (const [i, w] of remaining.entries()) { + if (w.sequenceNumber !== i + 1) { + await manager.getRepository(Wagon).update(w.id, { sequenceNumber: i + 1 }); + } + } + } + // Planned COUPLES standing at a passed stop join the train here — + // before the position fix below, so they ride it from this checkpoint + // on. Unavailable wagons are skipped silently (a checkpoint log must + // never fail on a missing planned couple); passedYardIds self-heals + // skipped logs, and an already-joined wagon has trainId set. + const couplePlan = schedule.plannedWagonCouples ?? {}; + const coupleNow = Object.entries(couplePlan).filter(([, yardId]) => + passedYardIds.includes(yardId), + ); + if (coupleNow.length && builtTrainId) { + const consist = await manager.getRepository(Wagon).find({ + where: { trainId: builtTrainId }, + select: { id: true, sequenceNumber: true }, + }); + let maxSeq = consist.reduce((m, w) => Math.max(m, w.sequenceNumber ?? 0), 0); + const coupleWagonById = new Map( + ( + await manager + .getRepository(Wagon) + .find({ where: { id: In(coupleNow.map(([wagonId]) => wagonId)) } }) + ).map((w) => [w.id, w]), + ); + const coupleLogRows: ScheduleWagonAdjustmentLog[] = []; + for (const [wagonId, coupleYardId] of coupleNow) { + const wagon = coupleWagonById.get(wagonId); + if ( + !wagon || + wagon.trainId || + wagon.status !== WagonStatus.Available || + wagon.currentTrainScheduleId || + wagon.currentYardId !== coupleYardId + ) { + continue; + } + maxSeq += 1; + await manager.getRepository(Wagon).update(wagonId, { + trainId: builtTrainId, + sequenceNumber: maxSeq, + status: WagonStatus.Assigned, + currentTrainScheduleId: scheduleId, + }); + coupleLogRows.push( + manager.getRepository(ScheduleWagonAdjustmentLog).create({ + trainScheduleId: scheduleId, + trainId: builtTrainId, + action: 'ADD', + wagonId, + wagonNumber: wagon.wagonNumber, + adjustedByUserId: null, + yardId: coupleYardId, + occurredAt, + }), + ); + } + if (coupleLogRows.length) { + await manager.getRepository(ScheduleWagonAdjustmentLog).save(coupleLogRows); + } + } await manager .getRepository(Wagon) .createQueryBuilder() @@ -4525,29 +4832,84 @@ export class TrainSchedulingService { ); } + // One fetch for every pinned wagon and bulk log/ledger inserts — the + // per-wagon UPDATEs stay (patches differ per wagon). + const pinnedIds = (schedule.trainSet?.wagons ?? []) + .map((slot) => slot.physicalWagonId) + .filter((id): id is string => Boolean(id)); + const settleWagonById = new Map( + pinnedIds.length + ? ( + await manager.getRepository(Wagon).find({ where: { id: In(pinnedIds) } }) + ).map((w) => [w.id, w]) + : [], + ); + const arrivalLogRows: ScheduleWagonAdjustmentLog[] = []; + const arrivalMovementRows: WagonMovement[] = []; for (const slot of schedule.trainSet?.wagons ?? []) { if (!slot.physicalWagonId) continue; - const wagon = await manager - .getRepository(Wagon) - .findOne({ where: { id: slot.physicalWagonId } }); + const wagon = settleWagonById.get(slot.physicalWagonId); if (!wagon) continue; // A wagon that already alighted mid-route (unload released it, possibly // re-pinned elsewhere since) is no longer this schedule's to move. if (wagon.currentTrainScheduleId !== scheduleId) continue; - // Dynamic consist: the wagon settles at its slot's alight yard, not - // blanket at the train's destination. - const settleYardId = slot.alightYardId ?? schedule.destinationStationId; - await manager.getRepository(Wagon).update(wagon.id, { - currentTrainScheduleId: null, - trainSetWagonId: null, - // A wagon that belongs to a built train stays coupled to it (ASSIGNED); - // only loose wagons return to the open AVAILABLE pool. - status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, - currentYardId: settleYardId, - }); + // Dynamic consist: the wagon settles at its planned cut yard first, + // then its slot's alight yard — never blanket at the train's + // destination. Covers journeys logged with only a final arrival: cut + // wagons still settle at their cut yard instead of teleporting to it. + const settleYardId = + schedule.plannedWagonCutYards?.[wagon.id] ?? + slot.alightYardId ?? + schedule.destinationStationId; + const ownerTrainId = wagon.trainId; + const isRealCut = + (schedule.plannedWagonRealCuts ?? []).includes(wagon.id) && + schedule.plannedWagonCutYards?.[wagon.id] != null; + if (isRealCut && ownerTrainId) { + // Arrival fallback for a journey logged without mid-route + // checkpoints: the REAL cut still permanently removes the wagon + // from the built train at its cut yard. + await manager.getRepository(Wagon).update(wagon.id, { + currentTrainScheduleId: null, + trainSetWagonId: null, + trainId: null, + sequenceNumber: null, + importTrainNumber: null, + exportTrainNumber: null, + status: WagonStatus.Available, + currentYardId: settleYardId, + }); + await manager.query( + `UPDATE freight.train_set_wagons SET physical_wagon_id = NULL + WHERE physical_wagon_id = $1 + AND train_set_id IN (SELECT id FROM freight.train_sets WHERE train_id = $2)`, + [wagon.id, ownerTrainId], + ); + arrivalLogRows.push( + manager.getRepository(ScheduleWagonAdjustmentLog).create({ + trainScheduleId: scheduleId, + trainId: ownerTrainId, + action: 'REMOVE', + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + adjustedByUserId: null, + yardId: settleYardId, + occurredAt: now, + }), + ); + } else { + await manager.getRepository(Wagon).update(wagon.id, { + currentTrainScheduleId: null, + trainSetWagonId: null, + // A wagon that belongs to a built train stays coupled to it (ASSIGNED); + // only loose wagons return to the open AVAILABLE pool. + status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, + currentYardId: settleYardId, + }); + } // Ledger: the wagon rode this schedule to its settle yard. const slotAllocations = slot.allocations ?? []; - await manager.getRepository(WagonMovement).save( + arrivalMovementRows.push( manager.getRepository(WagonMovement).create({ wagonId: wagon.id, fromYardId: slot.boardYardId ?? schedule.originStationId, @@ -4562,6 +4924,99 @@ export class TrainSchedulingService { ); } + // Planned couples: settle any that joined mid-route but have no pinned + // slot (the loop above never visits them), and — arrival fallback — + // join ones the checkpoint logs skipped: the train passed every stop, + // so a still-loose planned couple physically rode along. + const arrivalCouplePlan = schedule.plannedWagonCouples ?? {}; + const arrivalTrainId = schedule.trainSet?.trainId ?? null; + const coupleEntries = Object.entries(arrivalCouplePlan); + const arrivalCoupleById = new Map( + coupleEntries.length + ? ( + await manager + .getRepository(Wagon) + .find({ where: { id: In(coupleEntries.map(([wagonId]) => wagonId)) } }) + ).map((w) => [w.id, w]) + : [], + ); + // Join sequence numbers continue after the settled consist; the max is + // read once and incremented locally — identical to re-querying after + // each join, without one consist scan per wagon. + let arrivalMaxSeq: number | null = null; + for (const [coupleWagonId, coupleYardId] of coupleEntries) { + const wagon = arrivalCoupleById.get(coupleWagonId); + if (!wagon) continue; + if (wagon.currentTrainScheduleId === scheduleId) { + // Joined during the trip, slot-less: settle at the destination. + await manager.getRepository(Wagon).update(wagon.id, { + currentTrainScheduleId: null, + trainSetWagonId: null, + status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, + currentYardId: schedule.destinationStationId, + }); + arrivalMovementRows.push( + manager.getRepository(WagonMovement).create({ + wagonId: wagon.id, + fromYardId: coupleYardId, + toYardId: schedule.destinationStationId, + trainScheduleId: scheduleId, + kind: WagonMovementKind.EmptyReposition, + occurredAt: now, + }), + ); + } else if ( + arrivalTrainId && + !wagon.trainId && + wagon.status === WagonStatus.Available && + !wagon.currentTrainScheduleId && + wagon.currentYardId === coupleYardId + ) { + if (arrivalMaxSeq === null) { + const consist = await manager.getRepository(Wagon).find({ + where: { trainId: arrivalTrainId }, + select: { id: true, sequenceNumber: true }, + }); + arrivalMaxSeq = consist.reduce((m, w) => Math.max(m, w.sequenceNumber ?? 0), 0); + } + arrivalMaxSeq += 1; + await manager.getRepository(Wagon).update(wagon.id, { + trainId: arrivalTrainId, + sequenceNumber: arrivalMaxSeq, + status: WagonStatus.Assigned, + currentYardId: schedule.destinationStationId, + }); + arrivalLogRows.push( + manager.getRepository(ScheduleWagonAdjustmentLog).create({ + trainScheduleId: scheduleId, + trainId: arrivalTrainId, + action: 'ADD', + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + adjustedByUserId: null, + yardId: coupleYardId, + occurredAt: now, + }), + ); + arrivalMovementRows.push( + manager.getRepository(WagonMovement).create({ + wagonId: wagon.id, + fromYardId: coupleYardId, + toYardId: schedule.destinationStationId, + trainScheduleId: scheduleId, + kind: WagonMovementKind.EmptyReposition, + occurredAt: now, + }), + ); + } + } + if (arrivalLogRows.length) { + await manager.getRepository(ScheduleWagonAdjustmentLog).save(arrivalLogRows); + } + if (arrivalMovementRows.length) { + await manager.getRepository(WagonMovement).save(arrivalMovementRows); + } + // Ensure a destination checkpoint exists so the timeline shows ARRIVED. const stations = await this.buildScheduleStations(schedule); const finalStation = stations[stations.length - 1]; @@ -5023,6 +5478,9 @@ export class TrainSchedulingService { trainLimits, stops, stopLabels, + // Cargo exclusivity is a per-edge rule: a wagon may carry containers + // to Dire Dawa and bulk onward from there, never both at once. + legByBookingId, ), ); if (requireContainerPlacements && resolvedMode !== 'BULK') { @@ -5054,7 +5512,7 @@ export class TrainSchedulingService { // above — the whole-route totals here are informational (summary) only. The // locomotive checks below also compare per edge: a train is never heavier // than its heaviest leg, so disjoint legs must not be summed. - const perEdgeUsage = perEdgeConsistUsage(wagonPlan, stops); + const perEdgeUsage = perEdgeConsistUsage(wagonPlan, stops, legByBookingId); const maxEdgeGrossTons = roundTons( Math.max(0, ...perEdgeUsage.map((e) => e.grossWeightTons)), ); @@ -5329,6 +5787,39 @@ export class TrainSchedulingService { return rows[0]?.train_id ?? null; } + /** + * Polling heartbeat for the detail page: one row, no joins. Clients compare + * this snapshot between polls and refetch the (expensive) full detail only + * when it changed — `updatedAt` catches any schedule-row write, the phase + * fields drive countdowns directly. + */ + async getSchedulePhase(scheduleId: string) { + const rows: Array<{ + status: string; + bookingWindowStatus: string | null; + windowPhase: string | null; + windowOpensAt: Date | null; + windowClosesAt: Date | null; + docReviewEndsAt: Date | null; + paymentPhaseEndsAt: Date | null; + updatedAt: Date; + }> = await this.dataSource.query( + `SELECT status, + booking_window_status AS "bookingWindowStatus", + window_phase AS "windowPhase", + window_opens_at AS "windowOpensAt", + window_closes_at AS "windowClosesAt", + doc_review_ends_at AS "docReviewEndsAt", + payment_phase_ends_at AS "paymentPhaseEndsAt", + updated_at AS "updatedAt" + FROM freight.train_schedules + WHERE id = $1 AND deleted_at IS NULL`, + [scheduleId], + ); + if (!rows[0]) throw new NotFoundException(`Train schedule ${scheduleId} not found`); + return rows[0]; + } + /** `{ wagonId: yardId }` this schedule boards each wagon from; `{}` when unset. */ private async plannedWagonYardsOf( scheduleId: string | undefined, @@ -5343,17 +5834,61 @@ export class TrainSchedulingService { return rows[0]?.planned_wagon_yards ?? {}; } + /** `{ wagonId: yardId }` this schedule cuts each wagon at; `{}` when unset. */ + private async plannedWagonCutYardsOf( + scheduleId: string | undefined, + ): Promise> { + if (!scheduleId) return {}; + const rows: { planned_wagon_cut_yards: Record | null }[] = + await this.dataSource.query( + `SELECT planned_wagon_cut_yards FROM freight.train_schedules WHERE id = $1`, + [scheduleId], + ); + return rows[0]?.planned_wagon_cut_yards ?? {}; + } + + /** `{ wagonId: pickupYardId }` of loose wagons this schedule plans to couple; `{}` when unset. */ + private async plannedWagonCouplesOf( + scheduleId: string | undefined, + ): Promise> { + if (!scheduleId) return {}; + const rows: { planned_wagon_couples: Record | null }[] = + await this.dataSource.query( + `SELECT planned_wagon_couples FROM freight.train_schedules WHERE id = $1`, + [scheduleId], + ); + return rows[0]?.planned_wagon_couples ?? {}; + } + + /** Wagon types are near-static reference data — 60s TTL like the batch service's dims cache. */ + private wagonTypesCache: { value: WagonType[]; expiresAt: number } | null = null; + + private async loadWagonTypesCached(): Promise { + if (this.wagonTypesCache && this.wagonTypesCache.expiresAt > Date.now()) { + return this.wagonTypesCache.value; + } + const value = await this.dataSource.getRepository(WagonType).find(); + this.wagonTypesCache = { value, expiresAt: Date.now() + 60_000 }; + return value; + } + private async countFleetAvailability( originYardId: string, targetScheduleId?: string, ): Promise> { - const [wagons, wagonTypes, builtTrainId, pinnedToTargetIds, plan] = await Promise.all([ - this.dataSource.getRepository(Wagon).find(), - this.dataSource.getRepository(WagonType).find(), + const [wagonTypes, builtTrainId, pinnedToTargetIds, plan] = await Promise.all([ + this.loadWagonTypesCached(), this.builtTrainIdOfSchedule(targetScheduleId), this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId), this.plannedWagonYardsOf(targetScheduleId), ]); + // Only two wagon populations can ever count below: the built train's own + // consist, or (train-less schedules) loose wagons — `if (wagon.trainId) + // continue` used to drop everything else in JS after loading the whole + // national fleet. Same result, fleet-sized query avoided. + const wagons = await this.dataSource.getRepository(Wagon).find({ + where: builtTrainId ? { trainId: builtTrainId } : { trainId: IsNull() }, + }); const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code])); const counts = new Map(); @@ -5528,9 +6063,16 @@ export class TrainSchedulingService { slots: TrainSetWagon[], reverseWagonOrder = false, ) { - const wagons = await manager.getRepository(Wagon).find(); - const wagonTypes = await manager.getRepository(WagonType).find(); const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId, manager); + // pickPhysicalWagonForSlot can only ever pin the built train's own wagons, + // couple-planned loose wagons, or (loose-pool schedules) wagons with no + // train — its own filters reject everything else, so don't load the fleet. + const wagons = await manager.getRepository(Wagon).find({ + where: builtTrainId + ? [{ trainId: builtTrainId }, { trainId: IsNull() }] + : { trainId: IsNull() }, + }); + const wagonTypes = await this.loadWagonTypesCached(); const pinnedToScheduleIds = await this.pinnedPhysicalWagonIdsForSchedule( scheduleId, manager, @@ -5550,7 +6092,12 @@ export class TrainSchedulingService { const pinSchedule = await this.trainSchedulesRepository.findById(scheduleId); const stops = pinSchedule ? await this.stopYardsForSchedule(pinSchedule) : []; - const plannedYards = pinSchedule?.plannedWagonYards ?? {}; + const couplePlan = pinSchedule?.plannedWagonCouples ?? {}; + // Couples ride into the yard plan as boarding entries: a slot boarding at + // the couple yard may pin the (still loose) planned couple wagon. + const plannedYards = { ...(pinSchedule?.plannedWagonYards ?? {}), ...couplePlan }; + const cutPlan = pinSchedule?.plannedWagonCutYards ?? {}; + const coupleIds = new Set(Object.keys(couplePlan)); const unpinnable = this.findUnpinnableWagonSlots( planSlots, @@ -5561,6 +6108,8 @@ export class TrainSchedulingService { pinnedToScheduleIds, stops, plannedYards, + cutPlan, + coupleIds, ); if (unpinnable.length) { throw new BadRequestException({ @@ -5583,6 +6132,9 @@ export class TrainSchedulingService { pinnedToScheduleIds, reverseWagonOrder, plannedYards, + cutPlan, + stops, + coupleIds, ); if (!physical) continue; @@ -5607,11 +6159,17 @@ export class TrainSchedulingService { ): Promise { if (!wagonPlan.length) return []; - const [wagons, builtTrainId, pinnedToScheduleIds] = await Promise.all([ - this.dataSource.getRepository(Wagon).find(), + const [builtTrainId, pinnedToScheduleIds] = await Promise.all([ this.builtTrainIdOfSchedule(targetScheduleId), this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId), ]); + // Same population argument as autoPinWagonsForSchedule: consist + loose + // wagons are the only candidates the pin filters can accept. + const wagons = await this.dataSource.getRepository(Wagon).find({ + where: builtTrainId + ? [{ trainId: builtTrainId }, { trainId: IsNull() }] + : { trainId: IsNull() }, + }); const targetSchedule = targetScheduleId ? await this.trainSchedulesRepository.findById(targetScheduleId) : null; @@ -5632,7 +6190,13 @@ export class TrainSchedulingService { builtTrainId, pinnedToScheduleIds, stops, - targetSchedule?.plannedWagonYards ?? {}, + // Couples count as boarding entries at their couple yard. + { + ...(targetSchedule?.plannedWagonYards ?? {}), + ...(targetSchedule?.plannedWagonCouples ?? {}), + }, + targetSchedule?.plannedWagonCutYards ?? {}, + new Set(Object.keys(targetSchedule?.plannedWagonCouples ?? {})), ); } @@ -5666,6 +6230,8 @@ export class TrainSchedulingService { pinnedToScheduleIds: Set = new Set(), stops: string[] = [], plannedYards: PlannedWagonYards = {}, + cutPlan: Record = {}, + coupleIds: Set = new Set(), ): string[] { const violations: string[] = []; // One physical wagon may serve several slots whose leg spans don't overlap @@ -5686,6 +6252,9 @@ export class TrainSchedulingService { pinnedToScheduleIds, false, plannedYards, + cutPlan, + stops, + coupleIds, ); if (!physical) { violations.push( @@ -5717,7 +6286,18 @@ export class TrainSchedulingService { pinnedToScheduleIds: Set = new Set(), reverseWagonOrder = false, plannedYards: PlannedWagonYards = {}, + cutPlan: Record = {}, + stops: string[] = [], + coupleIds: Set = new Set(), ): Wagon | undefined { + // How far down the route a wagon rides before this schedule cuts it: + // stop index of its cut yard, or the last stop when uncut (also when the + // cut yard is unknown to this stop list — conservative full reach). + const reachIdxOf = (wagonId: string): number => { + const cutYardId = cutPlan[wagonId]; + const idx = cutYardId ? stops.indexOf(cutYardId) : -1; + return idx >= 0 ? idx : Math.max(1, stops.length - 1); + }; // Free for this slot = no already-assigned span on this wagon overlaps the // slot's own leg. Disjoint legs (alight before board) share the wagon. const spanFree = (wagonId: string): boolean => @@ -5747,9 +6327,15 @@ export class TrainSchedulingService { // consist views draw the schedule exactly like the train builder; a schedule // created with reverseWagonOrder pins back-to-front (physically-last wagon // takes slot #1). Unsequenced wagons sort after every sequenced one. + // A planned COUPLE (loose wagon joining at its couple yard) is pinnable + // alongside the train's own consist — its "boarding yard" is the couple + // yard, already merged into plannedYards by the callers. + const belongsToRun = (w: Wagon): boolean => + w.trainId === builtTrainId || + (coupleIds.has(w.id) && !w.trainId && w.status === WagonStatus.Available); const consistYards = new Set( wagons - .filter((w) => w.trainId === builtTrainId && scheduleYardOf(plannedYards, w)) + .filter((w) => belongsToRun(w) && scheduleYardOf(plannedYards, w)) .map((w) => scheduleYardOf(plannedYards, w) as string), ); // Split consist: a slot boarding at a given yard must take a wagon that @@ -5760,12 +6346,18 @@ export class TrainSchedulingService { const candidates = wagons .filter( (w) => - w.trainId === builtTrainId && + belongsToRun(w) && w.wagonTypeId === slot.wagonTypeId && spanFree(w.id) && + // A wagon cut before the slot's alight stop cannot serve it. + reachIdxOf(w.id) >= span[1] && (!requiredYardId || scheduleYardOf(plannedYards, w) === requiredYardId), ) .sort((a, b) => { + // Tightest sufficient reach first: cut-at-B wagons soak up A→B slots + // so full-route wagons stay free for slots that ride to the end. + const reachDelta = reachIdxOf(a.id) - reachIdxOf(b.id); + if (reachDelta !== 0) return reachDelta; if (a.sequenceNumber == null || b.sequenceNumber == null) { return (a.sequenceNumber == null ? 1 : 0) - (b.sequenceNumber == null ? 1 : 0); } @@ -5929,16 +6521,19 @@ export class TrainSchedulingService { builtTrainId: string, scheduleId?: string, ): Promise { - const [wagons, plan] = await Promise.all([ + const [wagons, plan, cutPlan, couplePlan] = await Promise.all([ this.dataSource.getRepository(Wagon).find({ where: { trainId: builtTrainId }, relations: { wagonType: true }, }), this.plannedWagonYardsOf(scheduleId), + this.plannedWagonCutYardsOf(scheduleId), + this.plannedWagonCouplesOf(scheduleId), ]); const remainingByTypeId = new Map(); const codesByTypeId = new Map(); const byYardId = new Map>(); + const cutWagons: NonNullable = []; for (const wagon of wagons) { remainingByTypeId.set( wagon.wagonTypeId, @@ -5952,6 +6547,38 @@ export class TrainSchedulingService { perType.set(wagon.wagonTypeId, (perType.get(wagon.wagonTypeId) ?? 0) + 1); byYardId.set(yardId, perType); } + // A wagon cut mid-route is not stock past its cut stop — consumers debit + // it per edge so "2 NW5 free from gmp" reads 1 when one is cut at Lebu. + const cutYardId = cutPlan[wagon.id]; + if (cutYardId) { + cutWagons.push({ wagonTypeId: wagon.wagonTypeId, poolYardId: yardId ?? '', cutYardId }); + } + } + // Planned couples: loose wagons joining the train mid-route are stock too, + // pooled at their couple yard so they serve bookings boarding there. + // ponytail: in multi-yard mode a couple serves only bookings boarding + // exactly at its couple yard (existing split-consist semantics) — + // conservative; upgrade = pool lookup falling back to the nearest pool + // at/before the leg's boarding edge. + const coupleIds = Object.keys(couplePlan); + if (coupleIds.length) { + const coupleWagons = await this.dataSource.getRepository(Wagon).find({ + where: { id: In(coupleIds) }, + relations: { wagonType: true }, + }); + for (const wagon of coupleWagons) { + // Already joined (or grabbed by another train) — counted via trainId then. + if (wagon.trainId) continue; + remainingByTypeId.set( + wagon.wagonTypeId, + (remainingByTypeId.get(wagon.wagonTypeId) ?? 0) + 1, + ); + if (wagon.wagonType) codesByTypeId.set(wagon.wagonTypeId, wagon.wagonType.code); + const coupleYardId = couplePlan[wagon.id]; + const perType = byYardId.get(coupleYardId) ?? new Map(); + perType.set(wagon.wagonTypeId, (perType.get(wagon.wagonTypeId) ?? 0) + 1); + byYardId.set(coupleYardId, perType); + } } // Single-yard consist (the overwhelming majority): the whole train is // offered at every boarding yard exactly as before — the per-yard split is @@ -5961,6 +6588,7 @@ export class TrainSchedulingService { remainingByTypeId, codesByTypeId, ...(byYardId.size > 1 ? { byYardId } : {}), + ...(cutWagons.length ? { cutWagons } : {}), }; } @@ -6021,6 +6649,36 @@ export class TrainSchedulingService { } } + /** + * Whole-route auto-fill for the secondary flows (previews, single-booking + * add): units that fit nowhere raise ONE grouped, human-readable error — + * never a clamp onto the last wagon that the placement validator then + * rejects once per container. Conservative on leg-sharing trains (treats a + * wagon's TEU as global), so it can say "full" where the main assign path's + * leg-aware fill would still fit — never the reverse. + */ + private autoFillOrFail( + units: ContainerUnitForPlacement[], + slots: number[], + ): ContainerPlacementInput[] { + const { placements, overflow } = autoFillPlacements(units, slots); + if (overflow.length) { + const byRef = new Map(); + for (const u of overflow) { + const ref = u.bookingReference ?? u.bookingId; + byRef.set(ref, (byRef.get(ref) ?? 0) + 1); + } + const detail = [...byRef.entries()] + .map(([ref, n]) => `${ref}: ${n} container(s) have no wagon space left`) + .join('; '); + throw new BadRequestException({ + message: `Booking validation failed: ${detail} — the train's container wagons are full`, + violations: [detail], + }); + } + return placements; + } + private async persistTrainSetWagons( manager: EntityManager, trainSetId: string, @@ -6048,6 +6706,9 @@ export class TrainSchedulingService { wagonPlan: WagonPlanSlot[], bookings: Booking[], containerPlacements: ContainerPlacementInput[] = [], + /** Booking id → stop-index span, for the per-edge exclusivity guard. */ + legs?: Map, + edgeCount = 1, ) { const bookingById = new Map(bookings.map((b) => [b.id, b])); const lineById = new Map( @@ -6081,6 +6742,20 @@ export class TrainSchedulingService { const trainSetWagon = savedWagons[i]; if (!slot || !trainSetWagon) continue; + // Last line of defense behind validateWagonCargoExclusivity: while a + // bulk load rides, its wagon carries nothing else — no container and no + // second bulk booking. Loads on DISJOINT legs (a container that alights + // where the bulk boards) legitimately share the wagon, so the check is + // per corridor edge, using the same leg spans the plan was built with. + const exclusivityIssues = validateWagonCargoExclusivity( + [slot], + legs, + edgeCount, + ); + if (exclusivityIssues.length) { + throw new BadRequestException(exclusivityIssues[0]); + } + for (const alloc of slot.allocations) { const savedAllocation = await manager.getRepository(WagonBookingAllocation).save( manager.getRepository(WagonBookingAllocation).create({ @@ -6362,7 +7037,9 @@ export class TrainSchedulingService { * (already carrying this schedule's cargo). */ async getScheduleWagonYards(scheduleId: string) { - const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + // Slim graph: this read needs stops, the built train, and which slots + // carry allocations — not the full booking/container branches. + const schedule = await this.trainSchedulesRepository.findByIdWithConsistLite(scheduleId); if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`); const builtTrain = schedule.trainSet?.train; if (!builtTrain) { @@ -6373,11 +7050,21 @@ export class TrainSchedulingService { const stops = this.mapScheduleStops(schedule); const pickupYardIds = new Set(stops.slice(0, -1).map((s) => s.yardId)); const plan = schedule.plannedWagonYards ?? {}; + const cutPlan = schedule.plannedWagonCutYards ?? {}; + const couplePlan = schedule.plannedWagonCouples ?? {}; + const realCuts = new Set(schedule.plannedWagonRealCuts ?? []); const wagons = await this.dataSource.getRepository(Wagon).find({ where: { trainId: builtTrain.id }, relations: { wagonType: true, currentYard: true }, order: { sequenceNumber: 'ASC' }, }); + const coupleIds = Object.keys(couplePlan); + const coupleWagons = coupleIds.length + ? await this.dataSource.getRepository(Wagon).find({ + where: { id: In(coupleIds) }, + relations: { wagonType: true, currentYard: true }, + }) + : []; const lockedIds = new Set( (schedule.trainSet?.wagons ?? []) .filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0) @@ -6385,7 +7072,7 @@ export class TrainSchedulingService { ); const offRouteYardIds = [ ...new Set( - wagons + [...wagons, ...coupleWagons] .flatMap((w) => [scheduleYardOf(plan, w), w.currentYardId]) .filter((y): y is string => !!y && !stops.some((s) => s.yardId === y)), ), @@ -6400,6 +7087,7 @@ export class TrainSchedulingService { const rows = wagons.map((w) => { const plannedYardId = scheduleYardOf(plan, w); + const cutYardId: string | null = cutPlan[w.id] ?? null; const locked = lockedIds.has(w.id); return { id: w.id, @@ -6412,17 +7100,50 @@ export class TrainSchedulingService { physicalYardLabel: w.currentYardId ? labels.get(w.currentYardId) ?? w.currentYardId : null, plannedYardId, plannedYardLabel: plannedYardId ? labels.get(plannedYardId) ?? plannedYardId : null, + cutYardId: cutYardId as string | null, + cutYardLabel: cutYardId ? labels.get(cutYardId) ?? cutYardId : null, + realCut: realCuts.has(w.id), + coupledYardId: null as string | null, + coupledYardLabel: null as string | null, aligned: plannedYardId === w.currentYardId, locked, lockReason: locked ? 'Carries cargo booked on this schedule' : null, }; }); + // Planned couples: loose wagons joining mid-route, appended after the + // consist so the table reads consist-first. + for (const w of coupleWagons) { + const coupledYardId = couplePlan[w.id]; + const locked = lockedIds.has(w.id); + rows.push({ + id: w.id, + wagonNumber: w.wagonNumber, + sequenceNumber: null, + wagonType: w.wagonType + ? { id: w.wagonType.id, code: w.wagonType.code, name: w.wagonType.name } + : { id: w.wagonTypeId, code: w.wagonTypeId, name: w.wagonTypeId }, + physicalYardId: w.currentYardId, + physicalYardLabel: w.currentYardId ? labels.get(w.currentYardId) ?? w.currentYardId : null, + plannedYardId: null, + plannedYardLabel: null, + cutYardId: null, + cutYardLabel: null, + realCut: false, + coupledYardId, + coupledYardLabel: labels.get(coupledYardId) ?? coupledYardId, + aligned: w.currentYardId === coupledYardId, + locked, + lockReason: locked ? 'Carries cargo booked on this schedule' : null, + }); + } const perStop = stops.map((s) => ({ yardId: s.yardId, label: s.label, pickup: pickupYardIds.has(s.yardId), planned: rows.filter((r) => r.plannedYardId === s.yardId).length, physical: rows.filter((r) => r.physicalYardId === s.yardId).length, + cut: rows.filter((r) => r.cutYardId === s.yardId).length, + coupled: rows.filter((r) => r.coupledYardId === s.yardId).length, })); return { scheduleId, @@ -6435,15 +7156,29 @@ export class TrainSchedulingService { } /** - * Re-plan which yard this departure boards wagons from. Only DRAFT/SCHEDULED - * schedules, only the train's own wagons, only pickup stops of the route, - * never a wagon already carrying this schedule's cargo. Physical yards are - * untouched — the train builder owns those. + * Re-plan this departure's consist plan: boarding yard (`yardId`), cut yard + * (`cutYardId`; null clears), the `realCut` flag (permanent removal from the + * built train at the cut), and mid-route COUPLES of loose wagons + * (`couple`/`uncouple`). Only DRAFT/SCHEDULED schedules; boarding/coupling + * only at pickup stops, cutting only at drop stops after the boarding yard + * and never before allocated cargo's destination. Coupling validates every + * leg the new wagon rides against the locomotives' weight/length caps. + * Physical yards are untouched — the train builder owns those. */ async updateScheduleWagonYards( scheduleId: string, - moves: Array<{ wagonId: string; yardId: string }>, + dto: { + moves?: Array<{ + wagonId: string; + yardId?: string; + cutYardId?: string | null; + realCut?: boolean; + }>; + couple?: Array<{ wagonId: string; yardId: string }>; + uncouple?: string[]; + }, ) { + const moves = dto.moves ?? []; const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`); const builtTrain = schedule.trainSet?.train; @@ -6464,7 +7199,7 @@ export class TrainSchedulingService { const pickupYardIds = new Set(stops.slice(0, -1).map((s) => s.yardId)); const wagons = await this.dataSource.getRepository(Wagon).find({ where: { trainId: builtTrain.id }, - select: { id: true, currentYardId: true, wagonNumber: true }, + relations: { wagonType: true }, }); const wagonById = new Map(wagons.map((w) => [w.id, w])); const lockedIds = new Set( @@ -6473,27 +7208,218 @@ export class TrainSchedulingService { .map((slot) => slot.physicalWagonId as string), ); + const stopIdx = new Map(stops.map((s, i) => [s.yardId, i])); + const dropYardIds = new Set(stops.slice(1).map((s) => s.yardId)); + // Furthest stop any allocated cargo rides to, per physical wagon — a cut + // must not strand cargo short of its destination (equal is fine: cargo + // alights there, then the wagon is cut). + const maxCargoDestIdx = new Map(); + for (const slot of schedule.trainSet?.wagons ?? []) { + if (!slot.physicalWagonId) continue; + for (const alloc of slot.allocations ?? []) { + const dest = alloc.booking?.destinationYardId; + const idx = dest != null ? stopIdx.get(dest) : undefined; + if (idx == null) continue; + const prev = maxCargoDestIdx.get(slot.physicalWagonId) ?? -1; + if (idx > prev) maxCargoDestIdx.set(slot.physicalWagonId, idx); + } + } + const plan: PlannedWagonYards = { ...(schedule.plannedWagonYards ?? {}) }; + const cutPlan: Record = { ...(schedule.plannedWagonCutYards ?? {}) }; + const couplePlan: Record = { ...(schedule.plannedWagonCouples ?? {}) }; + const realCuts = new Set(schedule.plannedWagonRealCuts ?? []); for (const move of moves) { const wagon = wagonById.get(move.wagonId); if (!wagon) { throw new BadRequestException(`Wagon ${move.wagonId} is not coupled to train ${builtTrain.code}`); } - if (!pickupYardIds.has(move.yardId)) { - throw new BadRequestException( - `Yard ${move.yardId} is not a pickup stop of this schedule's route`, - ); + if (move.yardId !== undefined) { + if (!pickupYardIds.has(move.yardId)) { + throw new BadRequestException( + `Yard ${move.yardId} is not a pickup stop of this schedule's route`, + ); + } + if (lockedIds.has(wagon.id) && scheduleYardOf(plan, wagon) !== move.yardId) { + throw new ConflictException( + `Wagon ${wagon.wagonNumber} already carries cargo booked on this schedule and cannot change yard`, + ); + } + plan[wagon.id] = move.yardId; } - if (lockedIds.has(wagon.id) && scheduleYardOf(plan, wagon) !== move.yardId) { - throw new ConflictException( - `Wagon ${wagon.wagonNumber} already carries cargo booked on this schedule and cannot change yard`, - ); + if (move.cutYardId === null) { + delete cutPlan[wagon.id]; + realCuts.delete(wagon.id); + } else if (move.cutYardId !== undefined) { + if (!dropYardIds.has(move.cutYardId)) { + throw new BadRequestException( + `Yard ${move.cutYardId} is not a drop stop of this schedule's route`, + ); + } + cutPlan[wagon.id] = move.cutYardId; + } + // Validate the combined final plan: boarding must precede the cut, + // whichever side this move changed. + const cutYardId = cutPlan[wagon.id]; + if (cutYardId !== undefined) { + const boardYardId = scheduleYardOf(plan, wagon); + const boardIdx = boardYardId != null ? stopIdx.get(boardYardId) ?? 0 : 0; + const cutIdx = stopIdx.get(cutYardId) as number; + if (cutIdx <= boardIdx) { + throw new BadRequestException( + `Wagon ${wagon.wagonNumber}: cut yard must come after its boarding yard on the route`, + ); + } + const cargoIdx = maxCargoDestIdx.get(wagon.id); + if (cargoIdx != null && cutIdx < cargoIdx) { + throw new ConflictException( + `Wagon ${wagon.wagonNumber} carries cargo booked to ${stops[cargoIdx].label} and cannot be cut earlier`, + ); + } + } + // Real-cut flag rides on the (now final) cut for this wagon. + if (move.realCut === false) { + realCuts.delete(wagon.id); + } else if (move.realCut === true) { + if (!cutPlan[wagon.id]) { + throw new BadRequestException( + `Wagon ${wagon.wagonNumber}: a real cut needs a cut yard — set where the wagon is cut first`, + ); + } + realCuts.add(wagon.id); } - plan[wagon.id] = move.yardId; } - await this.dataSource - .getRepository(TrainSchedule) - .update(scheduleId, { plannedWagonYards: plan }); + // A flag whose cut disappeared (any path) must not survive. + for (const id of [...realCuts]) if (!cutPlan[id]) realCuts.delete(id); + + // ── Couples: loose wagons planned to join the train at a pickup stop ── + const uncouple = new Set(dto.uncouple ?? []); + for (const id of uncouple) { + if (!couplePlan[id]) { + throw new BadRequestException(`Wagon ${id} is not in this schedule's couple plan`); + } + if (lockedIds.has(id)) { + throw new ConflictException( + 'Coupled wagon carries cargo booked on this schedule — free the bookings first', + ); + } + delete couplePlan[id]; + } + const coupleEntries = dto.couple ?? []; + if (new Set(coupleEntries.map((c) => c.wagonId)).size !== coupleEntries.length) { + throw new BadRequestException('A wagon appears more than once in the couple list'); + } + for (const c of coupleEntries) { + if (uncouple.has(c.wagonId)) { + throw new BadRequestException('A wagon cannot be both coupled and uncoupled in one save'); + } + if (!pickupYardIds.has(c.yardId)) { + throw new BadRequestException( + `Yard ${c.yardId} is not a pickup stop of this schedule's route`, + ); + } + if (wagonById.has(c.wagonId)) { + throw new BadRequestException( + `Wagon is already in train ${builtTrain.code}'s consist — use its yard/cut controls instead`, + ); + } + couplePlan[c.wagonId] = c.yardId; + } + if (coupleEntries.length) { + const incoming = await this.dataSource.getRepository(Wagon).find({ + where: { id: In(coupleEntries.map((c) => c.wagonId)) }, + relations: { wagonType: true }, + }); + const incomingById = new Map(incoming.map((w) => [w.id, w])); + const pinnedElsewhere = await this.wagonIdsPinnedToLiveSchedules(undefined, builtTrain.id); + for (const c of coupleEntries) { + const w = incomingById.get(c.wagonId); + if (!w) throw new BadRequestException(`Wagon ${c.wagonId} not found`); + if (w.trainId) { + throw new ConflictException( + `Wagon ${w.wagonNumber} is already coupled to another built train`, + ); + } + if (w.status !== WagonStatus.Available) { + throw new ConflictException(`Wagon ${w.wagonNumber} is not available (${w.status})`); + } + if (w.currentYardId !== c.yardId) { + throw new BadRequestException( + `Wagon ${w.wagonNumber} does not stand at the couple yard — it must physically wait where the train picks it up`, + ); + } + if (pinnedElsewhere.has(w.id)) { + throw new ConflictException( + `Wagon ${w.wagonNumber} is reserved by another live schedule`, + ); + } + } + } + + // ── Per-leg weight/length guard: a couple must fit every edge it rides ── + // Cuts alone only shrink load; the guard runs whenever couples remain in + // the final plan, so cut-then-couple in one save passes on the freed edge. + const coupleIds = Object.keys(couplePlan); + if (coupleIds.length) { + const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); + const pullCap = + (limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0); + const lenCap = + (limits?.maxTrainLengthMeters ?? 0) + (Number(limits?.overageToleranceMeters) || 0); + const edgeCount = Math.max(1, stops.length - 1); + const coupleWagons = await this.dataSource.getRepository(Wagon).find({ + where: { id: In(coupleIds) }, + relations: { wagonType: true }, + }); + const spanOfConsist = (w: Wagon) => ({ + fromEdge: stopIdx.get(scheduleYardOf(plan, w) ?? '') ?? 0, + toEdge: cutPlan[w.id] ? stopIdx.get(cutPlan[w.id]) ?? edgeCount : edgeCount, + tareTons: Number(w.wagonType?.tareWeightTons ?? 0), + lengthMeters: Number(w.wagonType?.lengthMeters ?? 0), + }); + const wagonSpans = [ + ...wagons.map(spanOfConsist), + ...coupleWagons.map((w) => ({ + fromEdge: stopIdx.get(couplePlan[w.id]) ?? 0, + toEdge: edgeCount, + tareTons: Number(w.wagonType?.tareWeightTons ?? 0), + lengthMeters: Number(w.wagonType?.lengthMeters ?? 0), + })), + ]; + const cargoLegs = (schedule.trainSet?.wagons ?? []).flatMap((slot) => + (slot.allocations ?? []).flatMap((alloc) => { + if (!alloc.booking) return []; + return [ + { + fromEdge: stopIdx.get(alloc.booking.originYardId) ?? 0, + toEdge: stopIdx.get(alloc.booking.destinationYardId) ?? edgeCount, + weightTons: Number(alloc.allocatedWeightTons ?? 0), + }, + ]; + }), + ); + const loads = computeEdgeLoads(edgeCount, wagonSpans, cargoLegs); + for (let e = 0; e < edgeCount; e += 1) { + const legLabel = `${stops[e].label} → ${stops[e + 1].label}`; + if (pullCap > 0 && loads[e].weightTons > pullCap) { + throw new BadRequestException( + `Leg ${legLabel}: coupling puts gross weight at ${Math.round(loads[e].weightTons)}T, over the locomotives' ${Math.round(pullCap)}T limit — cut a wagon riding this leg first (real cut frees the train permanently)`, + ); + } + if (lenCap > 0 && loads[e].lengthMeters > lenCap) { + throw new BadRequestException( + `Leg ${legLabel}: coupling puts train length at ${Math.round(loads[e].lengthMeters)}m, over the ${Math.round(lenCap)}m limit — cut a wagon riding this leg first`, + ); + } + } + } + + await this.dataSource.getRepository(TrainSchedule).update(scheduleId, { + plannedWagonYards: plan, + plannedWagonCutYards: cutPlan, + plannedWagonCouples: couplePlan, + plannedWagonRealCuts: [...realCuts], + }); // ponytail: per-stop over-booking check counts bookings boarding at the // stop against wagons planned there, ignoring leg sharing — a warning, not @@ -7285,6 +8211,10 @@ export class TrainSchedulingService { trainSetWagonId: null, currentTrainScheduleId: null, currentYardId, + // A wagon leaving the build sheds its run numbers, same as the train + // builder's removeWagon — they belong to the train, not the wagon. + importTrainNumber: null, + exportTrainNumber: null, }; for (const wagon of removed) { await manager.getRepository(Wagon).update(wagon.id, detachPatch); @@ -7430,7 +8360,7 @@ export class TrainSchedulingService { * through iam.users; rows survive wagon/train deletion (log tables carry * plain columns, no FKs). */ - async getScheduleHistory(scheduleId: string) { + async getScheduleHistory(scheduleId: string, query: { page?: number; pageSize?: number } = {}) { type HistoryRow = { id: string; kind: 'WAGON' | 'BOOKING'; @@ -7441,105 +8371,83 @@ export class TrainSchedulingService { note: string | null; occurredAt: Date; }; - const wagonRows: HistoryRow[] = ( - await 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", - 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 - WHERE l.train_schedule_id = $1 - AND l.deleted_at IS NULL - ORDER BY l.occurred_at DESC - LIMIT 200`, + const { page, pageSize, skip, take } = normalizePagination(query); + // One UNION ALL over the four event sources, paginated in SQL — the old + // shape capped each source at 200 and merge-sorted up to 800 rows in + // memory per request. Same rows, same order, same field mapping. + const historyCte = ` + SELECT l.id::text AS "id", + 'WAGON' AS "kind", + l.action AS "action", + l.wagon_number AS "subject", + COALESCE(y.label, y.code) AS "yardLabel", + COALESCE(u.username, u.email) AS "actor", + NULL::text AS "note", + 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 + WHERE l.train_schedule_id = $1 + AND l.deleted_at IS NULL + UNION ALL + SELECT r.id::text, + 'BOOKING', + 'BOOKING_REMOVED', + r.booking_reference, + NULL, + COALESCE(u.username, u.email), + r.notes, + r.removed_at + FROM freight.train_composition_removal_logs r + LEFT JOIN iam.users u ON u.id = r.removed_by_user_id + WHERE r.schedule_id = $1 + AND r.deleted_at IS NULL + UNION ALL + SELECT b.id::text, + 'BOOKING', + 'BOOKING_LOADED', + b.reference, + COALESCE(oy.label, oy.code), + COALESCE(u.username, u.email), + NULL, + b.loaded_at + FROM freight.bookings b + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL + LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id + LEFT JOIN iam.users u ON u.id = b.loaded_by_user_id + WHERE b.loaded_at IS NOT NULL + AND b.deleted_at IS NULL + UNION ALL + SELECT b.id::text, + 'BOOKING', + 'BOOKING_UNLOADED', + b.reference, + COALESCE(dy.label, dy.code), + COALESCE(u.username, u.email), + NULL, + b.arrived_at + FROM freight.bookings b + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN iam.users u ON u.id = b.arrived_by_user_id + WHERE b.arrived_at IS NOT NULL + AND b.deleted_at IS NULL`; + const [countRows, rows]: [Array<{ total: string }>, HistoryRow[]] = await Promise.all([ + this.dataSource.query( + `SELECT count(*) AS total FROM (${historyCte}) history`, [scheduleId], - ) - ).map((r: Omit) => ({ - ...r, - kind: 'WAGON' as const, - note: null, - })); - const bookingRows: HistoryRow[] = ( - await this.dataSource.query( - `SELECT r.id, - r.booking_reference AS "subject", - r.notes AS "note", - COALESCE(u.username, u.email) AS "actor", - r.removed_at AS "occurredAt" - FROM freight.train_composition_removal_logs r - LEFT JOIN iam.users u ON u.id = r.removed_by_user_id - WHERE r.schedule_id = $1 - AND r.deleted_at IS NULL - ORDER BY r.removed_at DESC - LIMIT 200`, - [scheduleId], - ) - ).map((r: Omit) => ({ - ...r, - kind: 'BOOKING' as const, - action: 'BOOKING_REMOVED', - yardLabel: null, - })); - // Per-booking journey events (load at boarding yard / unload at alighting - // yard) — sourced from the booking's own loaded_at/arrived_at stamps, so a - // multi-stop train's disjoint legs (a→b loads then unloads at b while a→c - // rides through) each show as their own row. Append-only: these columns are - // only ever set once per booking, never cleared, so rows never disappear. - const journeyRows: HistoryRow[] = ( - await this.dataSource.query( - `SELECT b.id, - b.reference AS "subject", - COALESCE(oy.label, oy.code) AS "yardLabel", - COALESCE(u.username, u.email) AS "actor", - b.loaded_at AS "occurredAt" - FROM freight.bookings b - JOIN freight.train_schedule_bookings tsb - ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL - LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id - LEFT JOIN iam.users u ON u.id = b.loaded_by_user_id - WHERE b.loaded_at IS NOT NULL - AND b.deleted_at IS NULL - ORDER BY b.loaded_at DESC - LIMIT 200`, - [scheduleId], - ) - ).map((r: Omit) => ({ - ...r, - kind: 'BOOKING' as const, - action: 'BOOKING_LOADED', - note: null, - })); - const unloadRows: HistoryRow[] = ( - await this.dataSource.query( - `SELECT b.id, - b.reference AS "subject", - COALESCE(dy.label, dy.code) AS "yardLabel", - COALESCE(u.username, u.email) AS "actor", - b.arrived_at AS "occurredAt" - FROM freight.bookings b - JOIN freight.train_schedule_bookings tsb - ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL - LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id - LEFT JOIN iam.users u ON u.id = b.arrived_by_user_id - WHERE b.arrived_at IS NOT NULL - AND b.deleted_at IS NULL - ORDER BY b.arrived_at DESC - LIMIT 200`, - [scheduleId], - ) - ).map((r: Omit) => ({ - ...r, - kind: 'BOOKING' as const, - action: 'BOOKING_UNLOADED', - note: null, - })); - return [...wagonRows, ...bookingRows, ...journeyRows, ...unloadRows].sort( - (a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(), - ); + ), + this.dataSource.query( + `SELECT * FROM (${historyCte}) history + ORDER BY "occurredAt" DESC + LIMIT $2 OFFSET $3`, + [scheduleId, take, skip], + ), + ]); + const total = Number(countRows[0]?.total ?? 0); + return { items: rows, meta: buildPaginationMeta(total, page, pageSize) }; } /** @@ -7921,6 +8829,10 @@ export class TrainSchedulingService { weightTons: Number.POSITIVE_INFINITY, lengthMeters: Number.POSITIVE_INFINITY, }); + // Wagons staff plan to cut mid-route are gone from every edge past the + // cut; planned couples add a slot from their couple stop onward. + subtractCutWagons(budget, schedule.plannedWagonCutYards); + addCoupledWagons(budget, schedule.plannedWagonCouples); for (const sb of schedule.scheduleBookings ?? []) { if (!sb.booking) continue; budget.subtract( @@ -8248,8 +9160,14 @@ export class TrainSchedulingService { .find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } }); milestoneYards = milestones.map((m) => m.yardId); } + // Oriented to THIS schedule's endpoints: a route traversed backwards + // (return-leg reuse) must not silently no-op every cut/leg lookup. const raw = milestoneYards.length >= 2 - ? milestoneYards + ? orientStopsToSchedule( + milestoneYards, + schedule.originStationId, + schedule.destinationStationId, + ) : [schedule.originStationId, ...milestoneYards, schedule.destinationStationId]; const unique: string[] = []; for (const yardId of raw) { @@ -8587,6 +9505,24 @@ export class TrainSchedulingService { // enforcement; coupled-but-empty consist wagons ride every edge. const heaviestLeg = schedule.trainSet ? (() => { + const legStops = this.mapScheduleStops(schedule).map((s) => s.yardId); + const legStopIdx = new Map(legStops.map((yardId, i) => [yardId, i])); + // Booking id → the stop-index span its cargo actually rides. Without + // this map a shared slot's FULL cargo counts on every edge the slot + // spans, over-reporting the heaviest leg (S-2026-00045 read 3703T on + // a leg that truly carried 2905T). Unknown yards fall back to the + // slot's whole span inside slotCargoOnEdge — conservative, as before. + const legByBookingId = new Map(); + for (const slot of schedule.trainSet.wagons ?? []) { + for (const alloc of slot.allocations ?? []) { + const booking = alloc.booking; + if (!booking || legByBookingId.has(alloc.bookingId)) continue; + legByBookingId.set(alloc.bookingId, { + from: legStopIdx.get(booking.originYardId) ?? -1, + to: legStopIdx.get(booking.destinationYardId) ?? -1, + }); + } + } const usage = maxEdgeConsistUsage( [ ...(schedule.trainSet.wagons ?? []).map((w) => ({ @@ -8606,7 +9542,8 @@ export class TrainSchedulingService { allocations: [], })), ], - this.mapScheduleStops(schedule).map((s) => s.yardId), + legStops, + legByBookingId, ); return { grossWeightTons: roundTons(usage.grossWeightTons), @@ -8789,6 +9726,13 @@ export class TrainSchedulingService { allocatedWeightTons: roundTons(Number(allocation.allocatedWeightTons)), loadType: allocation.loadType ?? null, status: allocation.status, + // THIS load's own corridor, not the wagon's union span. + // A wagon reused across disjoint legs carries two loads + // with different yards; without these the leg board can + // only draw one merged bar and cannot say which load + // rides which leg. + originYardId: allocation.booking?.originYardId ?? null, + destinationYardId: allocation.booking?.destinationYardId ?? null, containerItems: (containerItemsByAllocation.get(allocation.id) ?? []).map( (item) => ({ id: item.id, @@ -8919,12 +9863,24 @@ export class TrainSchedulingService { * yardId → display label for error messages that name corridor legs. One * query; unknown ids fall back to the raw id so a message never goes blank. */ + private yardLabelsCache: { value: Map; expiresAt: number } | null = null; + private async yardLabelMap(yardIds: string[]): Promise> { if (!yardIds.length) return new Map(); - const yards = await this.dataSource - .getRepository(Yard) - .find({ where: { id: In(yardIds) } }); - return new Map(yards.map((y) => [y.id, y.label || y.code || y.id])); + // Yards are near-static — cache the whole label map for 60s instead of + // one IN(...) query per detail/board render. A missing id degrades exactly + // as before: the consumer falls back to the raw id. + if (!this.yardLabelsCache || this.yardLabelsCache.expiresAt <= Date.now()) { + const yards = await this.dataSource.getRepository(Yard).find(); + this.yardLabelsCache = { + value: new Map(yards.map((y) => [y.id, y.label || y.code || y.id])), + expiresAt: Date.now() + 60_000, + }; + } + const all = this.yardLabelsCache.value; + return new Map( + yardIds.filter((id) => all.has(id)).map((id) => [id, all.get(id) as string]), + ); } /** Ordered corridor stops with labels, from the loaded route graph (no extra query). */ @@ -8934,27 +9890,44 @@ export class TrainSchedulingService { const milestones = [...(schedule.route?.milestones ?? [])].sort( (a, b) => a.sequenceNo - b.sequenceNo, ); - const raw = milestones.length >= 2 - ? milestones.map((m) => ({ - yardId: m.yardId, - label: m.yard?.label ?? m.yard?.code ?? m.yardId, - })) - : [ - { - yardId: schedule.originStationId, - label: - schedule.originStation?.label ?? - schedule.originStation?.code ?? - schedule.originStationId, - }, - { - yardId: schedule.destinationStationId, - label: - schedule.destinationStation?.label ?? - schedule.destinationStation?.code ?? - schedule.destinationStationId, - }, - ]; + const milestoneStops = milestones.map((m) => ({ + yardId: m.yardId, + label: m.yard?.label ?? m.yard?.code ?? m.yardId, + })); + // Same stop shape and orientation as stopYardsForSchedule / stopYardsFor — + // the three builders MUST agree, or validation rejects cuts that capacity + // would honour. Short routes keep a stray milestone as a middle stop; a + // backwards-traversed route is oriented to this schedule's endpoints. + let raw: Array<{ yardId: string; label: string }>; + if (milestoneStops.length >= 2) { + const oriented = orientStopsToSchedule( + milestoneStops.map((s) => s.yardId), + schedule.originStationId, + schedule.destinationStationId, + ); + raw = + oriented[0] === milestoneStops[0]?.yardId + ? milestoneStops + : [...milestoneStops].reverse(); + } else { + raw = [ + { + yardId: schedule.originStationId, + label: + schedule.originStation?.label ?? + schedule.originStation?.code ?? + schedule.originStationId, + }, + ...milestoneStops, + { + yardId: schedule.destinationStationId, + label: + schedule.destinationStation?.label ?? + schedule.destinationStation?.code ?? + schedule.destinationStationId, + }, + ]; + } const seen = new Set(); return raw.filter((stop) => { if (!stop.yardId || seen.has(stop.yardId)) return false; @@ -9051,7 +10024,7 @@ export class TrainSchedulingService { const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER'); const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings); const slots = getContainerSlotSequenceNos(validation.wagonPlan); - const placements = autoFillPlacements(units, slots); + const placements = this.autoFillOrFail(units, slots); const missingForBooking = findMissingContainerNumberIssues(units, placements).find( (m) => m.bookingId === bookingId, ); @@ -9183,7 +10156,7 @@ export class TrainSchedulingService { const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER'); const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings); const slots = getContainerSlotSequenceNos(validation.wagonPlan); - const placements = autoFillPlacements(units, slots); + const placements = this.autoFillOrFail(units, slots); const missingForGov = findMissingContainerNumberIssues(units, placements).find( (m) => m.bookingId === governmentBookingId, ); @@ -9321,7 +10294,7 @@ export class TrainSchedulingService { const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER'); const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings); const slots = getContainerSlotSequenceNos(validation.wagonPlan); - const placements = autoFillPlacements(units, slots); + const placements = this.autoFillOrFail(units, slots); const missingNumbers = findMissingContainerNumberIssues(units, placements); const missingByBooking = new Map(); for (const m of missingNumbers) { @@ -10023,7 +10996,20 @@ export class TrainSchedulingService { if (containerBookings.some((b) => b.id === booking.id)) { const units = expandBookingContainerUnits(containerBookings); const slots = getContainerSlotSequenceNos(validation.wagonPlan); - const placements = autoFillPlacements(units, slots); + // Availability probe — never throws. Overflow reads as "cannot assign", + // not a 500 on the board. + const { placements, overflow } = autoFillPlacements(units, slots); + const overflowHere = overflow.filter((u) => u.bookingId === booking.id).length; + if (overflowHere > 0) { + return { + wagonsRequired, + requiredWagonTypeCode, + yardWagonsAvailable, + canAssign: false, + blockReason: `${overflowHere} container(s) have no wagon space left on this train`, + shortage: null, + }; + } const missing = findMissingContainerNumberIssues(units, placements).find( (m) => m.bookingId === booking.id, ); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/fleet-plan.util.ts index 1dbd6e05a..d5763dfc8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/utils/fleet-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/fleet-plan.util.ts @@ -35,11 +35,34 @@ export type DeferredBookingRow = { shortage?: BookingWagonShortage | null; }; +/** A booking the customer has already paid for. */ +const isPaid = (booking: Booking): boolean => + booking.paymentStatus === 'PAID' || booking.status === 'PAID'; + +/** + * Seating order for the wagon planner. + * + * Government first, then PAID bookings, then priority score, then date. + * + * Payment ranks above priority score on purpose: money has changed hands and + * the customer was promised space on THIS train. Without it the planner + * seated an unpaid booking that merely arrived earlier and left a paid one + * with no wagon — the reported S-2026-00045 case, where a paid 695T bulk + * booking lost every wagon to unpaid container bookings and vanished from + * the train with free PW2 still standing in the consist. + * + * This only decides who is seated FIRST when the train is oversubscribed. It + * never invents capacity: an oversubscribed train still defers someone, and + * that someone is now the party who has not paid. + */ export function sortBookingsForScheduling(bookings: Booking[]): Booking[] { return [...bookings].sort((a, b) => { const govDiff = Number(Boolean(b.isGovernment)) - Number(Boolean(a.isGovernment)); if (govDiff !== 0) return govDiff; + const paidDiff = Number(isPaid(b)) - Number(isPaid(a)); + if (paidDiff !== 0) return paidDiff; + const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0); if (priorityDiff !== 0) return priorityDiff; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts index 52176662a..a9741b476 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts @@ -14,6 +14,8 @@ import { sumWagonsRequired, validate20ftContainerRules, validateContainerPlacements, + validateMixedTrainLimitsPerEdge, + validateWagonCargoExclusivity, } from './wagon-plan.util'; const nw5: WagonType = { @@ -222,6 +224,85 @@ describe('wagon-plan.util', () => { expect(plan[0]?.slotLoadType).toBe('BULK'); 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', () => { @@ -332,4 +413,91 @@ describe('maxEdgeConsistUsage — the binding edge, not the whole-route sum', () 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); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts index 01c497262..2df626a84 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts @@ -200,16 +200,14 @@ export function buildBulkWagonPlan( ); const cappedTonSlots = cappedTonSlotsByBooking.reduce((sum, n) => sum + n, 0); - const totalWeight = roundTons( - bookings.reduce( - (sum, b, i) => - itemSlotsByBooking[i] > 0 || cappedTonSlotsByBooking[i] > 0 - ? sum - : sum + Number(b.cargoTotalWeightVgm ?? 0), - 0, - ), - ); - const tonSlots = totalWeight > 0 ? Math.ceil(totalWeight / capacity) : 0; + // One bulk booking per wagon — bookings never pool tonnage on a shared + // wagon, so each uncapped booking sizes its own wagons (ceil per booking, + // not over the pooled total). + const tonSlots = bookings.reduce((sum, b, i) => { + if (itemSlotsByBooking[i] > 0 || cappedTonSlotsByBooking[i] > 0) return sum; + const weight = roundTons(Number(b.cargoTotalWeightVgm ?? 0)); + return weight > 0 ? sum + Math.ceil(weight / capacity) : sum; + }, 0); const slots = Math.max(1, tonSlots + itemSlots + cappedTonSlots); const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({ @@ -374,13 +372,12 @@ function allocateBookingsToSlots( if (booking.remainingWeightTons <= 0) { 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 }; @@ -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, + 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[] { const violations: string[] = []; for (const slot of wagonPlan.filter((s) => s.slotLoadType === 'BULK')) { @@ -530,6 +575,9 @@ export function validateTrainLimits( wagonPlan: WagonPlanSlot[], wagonType: Pick, limits?: TrainLimitConfig, + /** Leg-aware cargo exclusivity — see {@link validateWagonCargoExclusivity}. */ + legs?: Map, + edgeCount?: number, ): string[] { const maxWeightTons = limits?.maxWeightTons ?? MAX_TRAIN_WEIGHT_TONS; const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS; @@ -547,6 +595,7 @@ export function validateTrainLimits( ); violations.push(...validateBulkWagonSlotWeights(wagonPlan)); + violations.push(...validateWagonCargoExclusivity(wagonPlan, legs, edgeCount)); return violations; } @@ -560,6 +609,8 @@ export function validateMixedTrainLimits( wagonPlan: WagonPlanSlot[], wagonTypes: Array>, limits?: TrainLimitConfig, + legs?: Map, + edgeCount?: number, ): string[] { const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS; const minWagonLength = Math.min( @@ -573,6 +624,8 @@ export function validateMixedTrainLimits( wagonPlan, { lengthMeters: minWagonLength }, { ...limits, maxWagonsPerTrain }, + legs, + edgeCount, ); } @@ -590,17 +643,35 @@ export function validateMixedTrainLimitsPerEdge( stops: string[], /** Display names parallel to `stops` — violations then name the leg they hit. */ stopLabels?: string[], + /** Booking id → stop-index span, so cargo exclusivity is judged per edge. */ + legs?: Map, ): 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 label = (i: number) => stopLabels?.[i] ?? stops[i]; const violations = new Set(); for (let edge = 0; edge < stops.length - 1; edge += 1) { - const active = wagonPlan.filter( - (_, i) => spans[i].from <= edge && edge < spans[i].to, - ); + // A shared slot rides the UNION of its cargo legs, but only carries each + // 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; - 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}`); } } @@ -620,6 +691,40 @@ export type EdgeUsageSlot = Pick< 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, +): 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. */ function slotSpans( wagonPlan: EdgeUsageSlot[], @@ -644,8 +749,10 @@ function slotSpans( export function maxEdgeConsistUsage( wagonPlan: EdgeUsageSlot[], stops: string[], + /** Booking id → stop-index span; cargo then weighs only its own edges. */ + legs?: Map, ): { grossWeightTons: number; lengthMeters: number; loadedWagonCount: number } { - return perEdgeConsistUsage(wagonPlan, stops).reduce( + return perEdgeConsistUsage(wagonPlan, stops, legs).reduce( (max, e) => ({ grossWeightTons: Math.max(max.grossWeightTons, e.grossWeightTons), lengthMeters: Math.max(max.lengthMeters, e.lengthMeters), @@ -673,12 +780,19 @@ export type EdgeConsistUsage = { export function perEdgeConsistUsage( wagonPlan: EdgeUsageSlot[], 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, ): EdgeConsistUsage[] { + const edgeCount = Math.max(1, stops.length - 1); const totals = (edge: number, slots: EdgeUsageSlot[]): EdgeConsistUsage => ({ edge, grossWeightTons: slots.reduce( (sum, w) => - sum + Number(w.tareWeightTons ?? 0) + Number(w.assignedWeightTons ?? 0), + sum + Number(w.tareWeightTons ?? 0) + slotCargoOnEdge(w, edge, edgeCount, legs), 0, ), lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0), diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts index 127167567..967215f57 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts @@ -490,3 +490,112 @@ describe('planWagonsWithStock — consist split across yards', () => { 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); + } + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts index ad117663e..449033bfc 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -5,6 +5,7 @@ import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { bookingCargoTons, bulkItemsFitFor, + bulkTonsPerWagon, bulkWagonsForAllowedTypes, } from './train-capacity.util'; import { @@ -53,6 +54,13 @@ export type WagonStock = { * math. */ byYardId?: Map>; + /** + * 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 = { @@ -153,10 +161,46 @@ const shortageFor = ( ), ) : Math.max(1, containerWagonsForLines(booking.bookingContainers ?? [])); - const wagonsAvailable = candidates.reduce( - (sum, wt) => sum + availableOf(wt.id), - 0, - ); + + const freeByType = candidates.map((wt) => ({ wt, free: availableOf(wt.id) })); + const wagonsAvailable = freeByType.reduce((sum, c) => sum + c.free, 0); + + // PER_TON bulk: a bare wagon COUNT lies when the types carry different + // tonnage for this cargo. 14 NW5 (30T) + 10 PW2 (20T) is "24 wagons free" + // against a 24-wagon need, yet only 620T of the 695T booking fits — which + // is how a deferral could read "needs 24, 24 available (short 1)". Size the + // shortfall in the wagons the cargo's OWN caps require: how many more + // wagons of the best remaining type would carry the leftover tonnage. + const tons = bookingCargoTons(booking); + const perItem = + Number(booking.bulkTotalWeightTons ?? 0) > 0 && + Number(booking.cargoTotalWeightVgm ?? 0) > 0; + if (booking.freightType === 'BULK' && !perItem && tons > 0) { + let seatable = 0; + let usedWagons = 0; + for (const { wt, free } of freeByType) { + const perWagon = bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons)); + if (!(perWagon > 0) || free <= 0) continue; + seatable += free * perWagon; + usedWagons += free; + } + if (seatable < tons) { + const bestPerWagon = Math.max( + 1, + ...candidates.map((wt) => + bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons)), + ), + ); + return { + wagonTypeCodes: [...new Set(candidates.map((wt) => wt.code))].join('/'), + wagonsNeeded, + wagonsAvailable: usedWagons, + // Wagons of the best type still missing to carry the leftover tonnage. + wagonsShort: Math.max(1, Math.ceil((tons - seatable) / bestPerWagon)), + }; + } + } + return { wagonTypeCodes: [...new Set(candidates.map((wt) => wt.code))].join('/'), wagonsNeeded, @@ -187,8 +231,10 @@ const addAllocation = ( * 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: * 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 - * with a different cargo type. + * two 20ft, never mixed sizes); a bulk wagon carries ONE booking's cargo only, + * 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: { bookings: Booking[]; @@ -221,6 +267,38 @@ export function planWagonsWithStock(params: { const deferred: DeferredBookingRow[] = []; const configIssues = new Set(); + // 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(); + 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(); + 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 leg = legs?.get(booking.id); if (!leg || leg.from < 0 || leg.to > edgeCount || leg.from >= leg.to) { @@ -253,6 +331,16 @@ export function planWagonsWithStock(params: { } 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 pool = poolOf(leg); const total = totalFor(wagonTypeId, pool); @@ -277,18 +365,26 @@ export function planWagonsWithStock(params: { kind: SlotLoadType, cargoTypeId: string | null, leg: BookingLeg, + /** Bulk only: the booking's cargo type, for its per-wagon tonnage cap. */ + cargoType?: Booking['cargoType'], ): OpenSlot | PlacementProblem => { const inStock = candidates.filter((wt) => availableFor(wt.id, leg) > 0); if (!inStock.length) { 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. + const bulkTakeOf = (wt: WagonType): number => + bulkTonsPerWagon(cargoType, wt.id, Number(wt.capacityTons)); const chosen = [...inStock].sort((a, b) => 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), + : rankOf(a) - rankOf(b) || + availableFor(b.id, leg) - availableFor(a.id, leg), )[0]; const pool = poolOf(leg); const row = usedRow(rowKeyFor(chosen.id, pool)); @@ -298,7 +394,10 @@ export function planWagonsWithStock(params: { teuPerEdge: new Array(edgeCount).fill(0), kind, 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), covered: { ...leg }, pool, @@ -370,8 +469,14 @@ export function planWagonsWithStock(params: { } const allowedIds = new Set(candidates.map((wt) => wt.id)); 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 => - open.kind === 'CONTAINER' && + (open.kind === 'CONTAINER' || disjointFrom(open)) && allowedIds.has(open.slot.wagonTypeId) && teuFits(open, leg, teu) && canExtendSpan(open, leg); @@ -423,53 +528,80 @@ export function planWagonsWithStock(params: { const perItemTons = perItem ? remainingWeight / quantity : 0; let remainingItems = perItem ? quantity : 0; - /** Whole items one wagon of this slot's type can still take. */ - 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. */ + /** Fresh wagon's whole-item budget: items-fit map floor'd by (capped) tonnage. */ const itemBudgetOf = (open: OpenSlot): number => { const fit = bulkItemsFitFor(booking.cargoType, open.slot.wagonTypeId); const byTonnage = perItemTons > 0 - ? Math.max(1, Math.floor(Number(open.slot.capacityTons) / perItemTons)) + ? Math.max(1, Math.floor(open.freeCapacityTons / perItemTons)) : 1; return Math.min(fit ?? Number.MAX_SAFE_INTEGER, byTonnage); }; let placedAnywhere = false; - // Per-item: prefer the type carrying the most whole items per wagon. - // openSlot's own capacity sort is stable, so this order breaks its ties. + // Per-item: least-shareable type first (same scarcity rule as openSlot), + // then the type carrying the most whole items per wagon. const itemBudgetOfType = (wt: WagonType): number => Math.min( bulkItemsFitFor(booking.cargoType, wt.id) ?? Number.MAX_SAFE_INTEGER, 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, ); const orderedCandidates = perItem - ? [...candidates].sort((a, b) => itemBudgetOfType(b) - itemBudgetOfType(a)) + ? [...candidates].sort( + (a, b) => rankOf(a) - rankOf(b) || itemBudgetOfType(b) - itemBudgetOfType(a), + ) : candidates; - // Top off wagons already carrying THIS cargo type before opening new ones. - // ponytail: per-item cargo only shares wagons that were opened per-item - // (freeItems tracked); mixing itemized and loose loads of one cargo type - // on one wagon is not modeled — open a new wagon instead. - for (const open of openSlots) { + // One bulk booking per wagon PER LEG: a wagon carrying bulk takes that one + // booking's cargo for as long as it rides — never topped up from another + // booking on the same edges, even of the same cargo type. + // + // A wagon whose cargo ALIGHTS before this booking boards is free steel + // again, though: an import container uncoupled at Dire Dawa leaves its + // wagon empty for bulk loading there. Reuse those disjoint-leg slots + // before opening new stock — containers already do this, and without it a + // train with 3 wagons could not seat 3 wagons of leg-1 cargo plus 3 of + // leg-2 cargo. + const disjoint = (open: OpenSlot): boolean => + open.covered.to <= leg.from || leg.to <= open.covered.from; + const reusable = openSlots.filter( + (open) => + disjoint(open) && + allowedIds.has(open.slot.wagonTypeId) && + // A pooled wagon boards at its own yard; it cannot ride backwards. + !(open.pool && leg.from < open.covered.from), + ); + for (const open of reusable) { if (perItem ? remainingItems <= 0 : remainingWeight <= 0) break; - if (open.kind !== 'BULK') continue; - if (open.legKey !== legKey) continue; - if (open.cargoTypeId !== cargoTypeId) continue; - if (!allowedIds.has(open.slot.wagonTypeId)) continue; - if (open.freeCapacityTons <= 0) continue; - if (perItem !== (open.freeItems !== undefined)) continue; - const takeItems = perItem ? Math.min(itemRoomOf(open), remainingItems) : 0; - if (perItem && takeItems <= 0) continue; - const take = perItem - ? roundTons(takeItems * perItemTons) - : roundTons(Math.min(open.freeCapacityTons, remainingWeight)); + const wagonType = candidates.find((wt) => wt.id === open.slot.wagonTypeId); + if (!wagonType) continue; + const room = bulkTonsPerWagon( + booking.cargoType, + open.slot.wagonTypeId, + Number(open.slot.capacityTons), + ); + if (!(room > 0)) continue; + let take: number; + if (perItem) { + const budget = Math.min( + bulkItemsFitFor(booking.cargoType, open.slot.wagonTypeId) ?? + Number.MAX_SAFE_INTEGER, + perItemTons > 0 ? Math.max(1, Math.floor(room / perItemTons)) : 1, + ); + const takeItems = Math.max(1, Math.min(budget, remainingItems)); + take = roundTons(Math.min(takeItems * perItemTons, remainingWeight)); + remainingItems -= takeItems; + } else { + take = roundTons(Math.min(room, remainingWeight)); + } addAllocation( open.slot, booking.id, @@ -477,11 +609,9 @@ export function planWagonsWithStock(params: { take, AllocationLoadType.Bulk, ); - open.freeCapacityTons = roundTons(open.freeCapacityTons - take); - if (perItem) { - open.freeItems = (open.freeItems ?? 0) - takeItems; - remainingItems -= takeItems; - } + // The wagon now rides this leg too — it is the same physical steel, so + // no extra stock is consumed beyond extending its span. + extendSpan(open, leg); remainingWeight = roundTons(remainingWeight - take); placedAnywhere = true; } @@ -498,6 +628,7 @@ export function planWagonsWithStock(params: { 'BULK', cargoTypeId, leg, + booking.cargoType, ); if ('message' in openedSlot) return openedSlot; let take: number; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts index 5815ce435..a1d96e8e9 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts @@ -159,3 +159,52 @@ describe('WagonStockLedger — multi-yard consist', () => { 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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts index bf5b935ad..051d40c07 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts @@ -75,6 +75,32 @@ export class WagonStockLedger { 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(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 * mapped to several wagon types can use any of them, so they add up. diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts index dcaf81d4b..21431a1a4 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts @@ -16,6 +16,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CurrentUser } from '@edr/api-common'; 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 { resolveAuthUserId } from '../../common/resolve-auth-user-id'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; @@ -78,6 +79,24 @@ export class TrainBuilderController { 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') @FleetManage(FREIGHT_PERMS.trains.changeLocomotives) @ApiOperation({ summary: 'Replace the locomotive set (minimum 1, same yard)' }) diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index beac844e4..d7cfc29c5 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -231,6 +231,117 @@ export class TrainBuilderService { 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. */ async getComposition(id: string) { const train = await this.dataSource.getRepository(Train).findOne({ @@ -721,6 +832,7 @@ export class TrainBuilderService { toYardId: yardId, kind: WagonMovementKind.Maintenance, note: notes.movementNote, + movedByUserId: userId, occurredAt: new Date(), }), ); @@ -1097,15 +1209,14 @@ export class TrainBuilderService { .getRepository(TrainSet) .update(trainSet.id, { wagonCount, totalWeightTons, totalLengthMeters }); } - if (!schedule) return null; - - await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount }); - + // 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 + // which wagon, when, where), and the detached-wagons tab reads it back. const now = new Date(); await manager.getRepository(ScheduleWagonAdjustmentLog).save( changes.map((c) => manager.getRepository(ScheduleWagonAdjustmentLog).create({ - trainScheduleId: schedule.id, + trainScheduleId: schedule?.id ?? null, trainId, action: c.action, 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 // reconcileWindowAfterConsistChange. return { scheduleId: schedule.id, wasFull: schedule.bookingWindowStatus === 'FULL' }; diff --git a/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts index 4f48c2a8b..1e2db1631 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts @@ -91,4 +91,18 @@ export class ListWagonsQueryDto { @IsOptional() @IsDateString() 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; } diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index 2347a446c..1ae1093f2 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -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. if (search) { qb.andWhere( diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index e4ab41954..e611011f8 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -2,10 +2,15 @@ import { BOOKING_RULE_ENGINE_PERMISSIONS, BOOKING_RULE_ENGINE_PERMISSION_KEYS, deriveReadPermissions, + FREIGHT_PERMS, POSITION_PERMISSION_PRESETS, ROLE_PERMISSION_PRESETS, } from './freight-permissions.registry'; +/** Shorthand for the one overview-layout permission a role/position preset gets. */ +const overviewLayout = (key: Parameters[0]): string => + FREIGHT_PERMS.overview.layout(key); + export type FreightSeedRole = { key: string; name: { en: string }; @@ -248,48 +253,55 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ { key: "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", name: { en: "EDR Operations Officer" }, - permissionKeys: [...ROLE_PERMISSION_PRESETS.operationsOfficer], + permissionKeys: [ + ...ROLE_PERMISSION_PRESETS.operationsOfficer, + overviewLayout("operation"), + ], }, { key: "edr_director", name: { en: "EDR Director" }, - permissionKeys: [...ROLE_PERMISSION_PRESETS.director], + permissionKeys: [...ROLE_PERMISSION_PRESETS.director, overviewLayout("executive")], }, { key: "edr_ceo", name: { en: "EDR CEO" }, - permissionKeys: [...ROLE_PERMISSION_PRESETS.ceo], + permissionKeys: [...ROLE_PERMISSION_PRESETS.ceo, overviewLayout("executive")], }, { key: "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", name: { en: "EDR Marketing" }, - permissionKeys: [...ROLE_PERMISSION_PRESETS.marketing], + permissionKeys: [...ROLE_PERMISSION_PRESETS.marketing, overviewLayout("marketer")], }, { key: "edr_gl_ethiopia", name: { en: "EDR Global Logistics — Ethiopia" }, - permissionKeys: [...ROLE_PERMISSION_PRESETS.glEthiopia], + permissionKeys: [...ROLE_PERMISSION_PRESETS.glEthiopia, overviewLayout("clearance")], }, { key: "edr_gl_djibouti", name: { en: "EDR Global Logistics — Djibouti" }, - permissionKeys: [...ROLE_PERMISSION_PRESETS.glDjibouti], + permissionKeys: [...ROLE_PERMISSION_PRESETS.glDjibouti, overviewLayout("clearance")], }, { key: "edr_org_manager", name: { en: "EDR Org Manager" }, permissionKeys: [ ...BOOKING_RULE_ENGINE_PERMISSION_KEYS, + overviewLayout("executive"), ...EMPLOYEE_REGISTRATION_PERMISSIONS.map((p) => p.key), ...ROLE_ASSIGNMENT_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 * 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[] = [ - { key: "chief", name: { en: "Chief" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.chief] }, - { key: "director", name: { en: "Director" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.director] }, - { key: "ceo", name: { en: "CEO" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.ceo] }, - { key: "ethiopian_gl", name: { en: "Ethiopian GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.ethiopianGl] }, - { key: "djibouti_gl", name: { en: "Djibouti GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.djiboutiGl] }, - { key: "marketer", name: { en: "Marketer" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.marketer] }, - { key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation] }, - { key: "operations_chief", name: { en: "Operations Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.operationsChief] }, - { key: "dispatcher", name: { en: "Dispatcher" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.dispatcher] }, - { key: "truck_machinery_chief", name: { en: "Truck & Machinery Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.truckMachineryChief] }, + { 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, overviewLayout("executive")] }, + { 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, overviewLayout("clearance")] }, + { 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, overviewLayout("marketer")] }, + { 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, overviewLayout("operation")] }, + { 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, overviewLayout("operation")] }, ]; diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 1a6170336..8204ee2d3 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -49,13 +49,14 @@ const perm = (id: string, key: string, en: string): FreightPermissionSeed => ({ }); /** - * One entry per report definition (see modules/reports/definitions). Each - * gets its own permission, gated behind the `reports:view` master key that - * opens the Reports section itself. - * Keep new keys at the END: reportPermId derives ids from list index, so a - * mid-list insert would shift ids already seeded for later keys. + * Every report key ever seeded, in seed order. + * + * NEVER reorder or delete an entry: reportPermId derives a permission's uuid + * from its index here, so a shift would re-map ids already granted to roles. + * 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", "revenue-by-customer", "aging-receivables", @@ -98,21 +99,100 @@ export const REPORT_KEYS = [ "cargo-volume-by-station", ] 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`; const reportPermId = (index: number): string => `a4f00002-0001-4000-8000-${(index + 1).toString(16).padStart(12, "0")}`; 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( - (key, index) => - perm(reportPermId(index), reportPermissionKey(key), `Report: ${titleCase(key)}`), -); +// Seeded from SEEDED_REPORT_KEYS, not REPORT_KEYS: a retired report keeps its +// index and its permission row, which is what stops the live ids from moving. +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 = { + 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[] = [ perm( @@ -464,12 +544,12 @@ export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = ), ...(approveId ? [ - perm( - approveId, - `edr_freight_app:rule_engine:${resource}:approve`, - `Approve ${slug} changes`, - ), - ] + perm( + approveId, + `edr_freight_app:rule_engine:${resource}:approve`, + `Approve ${slug} changes`, + ), + ] : []), ]; }); @@ -572,8 +652,16 @@ export const SHIPPING_LINE_PERMISSIONS: FreightPermissionSeed[] = [ // Internal chat (Matrix/Element) — sidebar visibility + manual reconcile trigger. export const CHAT_PERMISSIONS: FreightPermissionSeed[] = [ - perm('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'), + perm( + "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 @@ -1381,6 +1469,20 @@ export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:train_scheduling:rules_manage", "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) @@ -1746,6 +1848,7 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ ...REPORT_PERMISSIONS, + ...OVERVIEW_LAYOUT_PERMISSIONS, ...CUSTOMER_PERMISSIONS, ...SHIPPING_LINE_PERMISSIONS, ...CHAT_PERMISSIONS, @@ -1907,6 +2010,15 @@ export const FREIGHT_PERMS = { cancel: "edr_freight_app:train_scheduling:cancel", reschedule: "edr_freight_app:train_scheduling:reschedule", 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", markPaid: "edr_freight_app:train_scheduling:mark_paid", 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 // to ANY pending request — including the holder's own. /** Request recording an offline payment against a credit invoice. */ - invoiceMarkPaid: - "edr_freight_app:shipping_line_credits:invoice_mark_paid", + invoiceMarkPaid: "edr_freight_app:shipping_line_credits:invoice_mark_paid", /** Request voiding a credit invoice (credits return to unbilled). */ invoiceCancel: "edr_freight_app:shipping_line_credits:invoice_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", }, chat: { - view: 'edr_freight_app:chat:view', - sync: 'edr_freight_app:chat:sync', + view: "edr_freight_app:chat:view", + sync: "edr_freight_app:chat:sync", }, payments: { view: "edr_freight_app:payments:view", @@ -2292,6 +2403,7 @@ export const FREIGHT_PERMS = { }, overview: { view: "edr_freight_app:overview:view", + layout: (key: OverviewLayoutKey): string => overviewLayoutPermissionKey(key), }, reports: { view: "edr_freight_app:reports:view", @@ -2444,7 +2556,8 @@ const FLEET_GRANULAR_KEYS: string[] = [ 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 // 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.create, FREIGHT_PERMS.trainScheduling.update, + FREIGHT_PERMS.trainScheduling.load, + FREIGHT_PERMS.trainScheduling.unload, FREIGHT_PERMS.trainScheduling.cancel, FREIGHT_PERMS.trainScheduling.reschedule, FREIGHT_PERMS.trainScheduling.rulesManage, @@ -2732,6 +2847,8 @@ export const POSITION_PERMISSION_PRESETS = { FREIGHT_PERMS.trainScheduling.view, FREIGHT_PERMS.trainScheduling.create, FREIGHT_PERMS.trainScheduling.update, + FREIGHT_PERMS.trainScheduling.load, + FREIGHT_PERMS.trainScheduling.unload, FREIGHT_PERMS.trainScheduling.cancel, FREIGHT_PERMS.trainScheduling.reschedule, FREIGHT_PERMS.trainScheduling.rulesManage, diff --git a/apps/edr-freight-api/src/seed/freight-positions.seeder.ts b/apps/edr-freight-api/src/seed/freight-positions.seeder.ts index 81df21a17..9357398c7 100644 --- a/apps/edr-freight-api/src/seed/freight-positions.seeder.ts +++ b/apps/edr-freight-api/src/seed/freight-positions.seeder.ts @@ -229,7 +229,18 @@ export class FreightPositionsSeeder { 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( `Granted ${rowsToInsert.length} permissions to position '${seed.key}'`, diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx index 38fda81e1..e6eb81a02 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx @@ -16,6 +16,7 @@ import { Textarea, Tooltip, } from "@mantine/core"; +import { DateInput } from "@mantine/dates"; import { Ban, Download, @@ -32,7 +33,7 @@ import { isViewable } from "@edr/ui-common"; import { bookingsService } from "@/services/bookings.service"; import { downloadBookingFile, fetchViewableFile } from "@/services/files.service"; -import { formatDateTime } from "@/lib/format"; +import { formatDate, formatDateTime } from "@/lib/format"; import { extractErrorMessage } from "@/utils/errorExtractor"; const CURRENCIES = ["ETB", "USD"]; @@ -75,6 +76,7 @@ export function AdditionalPaymentsTab({ bookingId, onViewFile }: AdditionalPayme currency: string; action: "draft" | "send"; file?: File | null; + dueDate?: string | null; }) => bookingsService.createAdditionalCharge(bookingId, p), onSuccess: (next, p) => { toast.success(p.action === "send" ? "Charge sent to the customer" : "Draft saved"); @@ -203,16 +205,29 @@ function ChargeCard({ {charge.cancelReason ? ` — ${charge.cancelReason}` : ""} )} + {charge.dueAt && charge.status !== "PAID" && charge.status !== "CANCELLED" && ( + + Due {formatDate(charge.dueAt)} + + )} - - - {charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "} - {charge.currency} - - - {meta.label} - + + + + {charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "} + {charge.currency} + + + {meta.label} + + + {charge.convertedAmount != null && ( + + ≈ {charge.convertedAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "} + {charge.convertedCurrency} + + )} @@ -297,12 +312,14 @@ function AddChargeModal({ currency: string; action: "draft" | "send"; file?: File | null; + dueDate?: string | null; }) => void; }) { const [reason, setReason] = useState(""); const [amount, setAmount] = useState(""); const [currency, setCurrency] = useState("ETB"); const [file, setFile] = useState(null); + const [dueDate, setDueDate] = useState(null); const valid = reason.trim().length > 0 && Number(amount) > 0; @@ -311,11 +328,23 @@ function AddChargeModal({ setAmount(""); setCurrency("ETB"); setFile(null); + setDueDate(null); }; const submit = (action: "draft" | "send") => { 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 ( @@ -355,6 +384,14 @@ function AddChargeModal({ w={100} /> + setDueDate(v ? new Date(v) : null)} + minDate={new Date()} + clearable + /> {(props) => ( + ) : null} + + + {query.isLoading ? ( + + Loading detached wagons… + + ) : rows.length === 0 ? ( + + No loose wagons were detached from this train — detach history starts + being recorded from now on. + + ) : ( + + + + {canAttach ? ( + + 0 && !allSelected} + onChange={(e) => + setSelected( + e.currentTarget.checked + ? new Set(rows.map((r) => r.wagonId)) + : new Set(), + ) + } + /> + + ) : null} + Wagon + Type + Now standing at + Last detached + + + + {rows.map((r) => ( + + {canAttach ? ( + + toggle(r.wagonId, e.currentTarget.checked)} + /> + + ) : null} + + + {r.wagonNumber} + + + + + {r.wagonTypeCode ?? "—"} + + + + {r.currentYardLabel ?? "No yard"} + + + + + {new Date(r.detachedAt).toLocaleDateString()} + + {r.detachedYardLabel ? ( + + + + at {r.detachedYardLabel} + + + ) : null} + {r.detachedBy ? ( + + + + by {r.detachedBy} + + + ) : null} + + + + ))} + +
+ )} + + {totalPages > 1 ? ( + + + {query.data?.meta.total ?? 0} wagon(s) · selection carries across pages + + + + ) : null} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/TrainHistoryPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/TrainHistoryPanel.tsx new file mode 100644 index 000000000..ec8b468a6 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/TrainHistoryPanel.tsx @@ -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 ( + + + + + + + + + Wagon history + + + Who attached, detached or switched which wagon on this train — from + the builder and from its trips — newest first. + + + + + {historyQuery.isLoading ? ( + + Loading history… + + ) : entries.length === 0 ? ( + + No wagon changes recorded yet for this train. + + ) : ( + + {entries.map((entry) => { + const meta = ACTION_META[entry.action] ?? ACTION_META.ADD; + const Icon = meta.icon; + return ( + } + color={meta.color} + title={ + + + {meta.label} + + {entry.subject ? ( + + {entry.subject} + + ) : null} + {entry.scheduleReference ? ( + } + > + {entry.scheduleReference} + + ) : ( + + Builder + + )} + + } + > + + + {new Date(entry.occurredAt).toLocaleString()} + + {entry.yardLabel ? ( + + + + at {entry.yardLabel} + + + ) : null} + {entry.actor ? ( + + + + {entry.actor} + + + ) : null} + + + ); + })} + + )} + + {totalPages > 1 ? ( + + + {total} change(s) + + + + ) : null} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/IntercityRideAlongPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/IntercityRideAlongPanel.tsx index 1636cfa6d..0342b9ae9 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/IntercityRideAlongPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/IntercityRideAlongPanel.tsx @@ -15,6 +15,8 @@ import { import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertCircle, ArrowRight, PackageCheck, PackageOpen, TrainFront } from "lucide-react"; +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions"; import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; import type { @@ -130,6 +132,9 @@ export function IntercityRideAlongPanel({ direction: string | null | undefined; }) { const { toast } = useToast(); + const { user } = useAuth(); + const canLoad = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.load); + const canUnload = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.unload); const queryClient = useQueryClient(); const [selected, setSelected] = useState([]); @@ -378,12 +383,19 @@ export function IntercityRideAlongPanel({ {row.status === "PAID" && ( - + + + ) : null} + + setCoupleModalOpen(false)} + size="xl" + radius="md" + title={ + + + Add wagons to this trip + + } + > + + + A wagon is coupled where it physically stands, so it must be waiting at one of this + route's stops between the origin and the destination. Wagons elsewhere are listed + but cannot be added until they are moved. + + + ({ + value: t.id, + label: t.code ? `${t.name} (${t.code})` : t.name, + }))} + value={coupleType} + onChange={(v) => { + setCoupleType(v); + setCouplePage(1); + }} + w={200} + /> + } + value={coupleSearch} + onChange={(e) => { + setCoupleSearch(e.currentTarget.value); + setCouplePage(1); + }} + w={200} + /> + + {coupleListQuery.isLoading ? ( + + + + ) : ( + + + + + Wagon + Type + Standing at + Couple + + + + {coupleCandidates.map((w) => { + const onTrip = data.wagons.some((row) => row.id === w.id); + const queued = w.id in pendingCouples; + const stop = intermediateStops.find((s) => s.yardId === w.currentYardId); + return ( + + + + {w.wagonNumber} + + + + + {w.wagonType?.code ?? w.wagonTypeId} + + + + {w.currentYard?.label ?? "No yard"} + + + {onTrip ? ( + + On this trip + + ) : queued ? ( + + ) : stop ? ( + + ) : ( + + + + )} + + + ); + })} + {coupleCandidates.length === 0 ? ( + + + + No loose wagons match the filters. + + + + ) : null} + +
+
+ )} + + {coupleTotalPages > 1 ? ( + + ) : ( + + )} + + + {Object.keys(pendingCouples).length} wagon(s) queued — save the plan to apply + + + + +
+
+ @@ -229,64 +592,223 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) { Type Physical yard Planned yard (this schedule) + Cut at (rides to) Status - {data.wagons.map((w) => { - const planned = effectiveYard(w); - const changed = w.id in pending; - return ( - - {w.sequenceNumber ?? "—"} - - - {w.wagonNumber} - - - {w.wagonType.code} - {w.physicalYardLabel ?? "No yard"} - - {editable && !w.locked ? ( - { + setPending((prev) => { + const next = { ...prev }; + if (!v || v === w.plannedYardId) delete next[w.id]; + else next[w.id] = v; + return next; + }); + setPendingCut((prev) => + clearInvalidCut({ ...prev }, w, v ?? w.plannedYardId), + ); + }} + w={180} + /> + ) : ( + + {yardLabel(planned)} + {w.locked ? ( + + + + ) : null} + + )} + + + {editable ? ( + // Locked wagons stay editable here — the server enforces the + // cargo-destination floor and the toast explains a 409. + +
@@ -295,7 +817,17 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) { {pendingCount} pending change(s) - @@ -773,13 +843,15 @@ export function ScheduleWorkspacePanel({ {showLoad ? ( @@ -788,13 +860,15 @@ export function ScheduleWorkspacePanel({ variant="filled" color="edr-green" radius="md" - disabled={!boardHere} + disabled={!boardHere || !canLoad} leftSection={} loading={ loadJourney.isPending && loadJourney.variables?.bookingId === b.id } - onClick={() => doLoad(b.id, ref)} + onClick={() => + setConfirmAction({ kind: "load", bookingId: b.id, ref }) + } > Load @@ -802,7 +876,11 @@ export function ScheduleWorkspacePanel({ ) : null} {showTruckToTrain ? ( @@ -821,9 +906,11 @@ export function ScheduleWorkspacePanel({ {showUnload ? ( @@ -832,13 +919,15 @@ export function ScheduleWorkspacePanel({ variant="light" color="orange" radius="md" - disabled={!alightHere} + disabled={!alightHere || !canUnload} leftSection={} loading={ unloadJourney.isPending && unloadJourney.variables?.bookingId === b.id } - onClick={() => doUnload(b.id, ref)} + onClick={() => + setConfirmAction({ kind: "unload", bookingId: b.id, ref }) + } > Unload @@ -857,7 +946,9 @@ export function ScheduleWorkspacePanel({ unassign.isPending && unassign.variables?.bookingId === b.id } - onClick={() => removeFromTrain(b.id, ref)} + onClick={() => + setConfirmAction({ kind: "remove", bookingId: b.id, ref }) + } > Remove @@ -961,6 +1052,82 @@ export function ScheduleWorkspacePanel({ + + {/* Confirm add / load / unload / remove */} + setConfirmAction(null)} + centered + radius="lg" + size="md" + withCloseButton={false} + title={ + confirmAction ? ( + + + {confirmAction.kind === "remove" ? ( + + ) : confirmAction.kind === "unload" ? ( + + ) : confirmAction.kind === "truckToTrain" ? ( + + ) : ( + + )} + +
+ {confirmMeta[confirmAction.kind].title} + + {confirmAction.ref} + +
+
+ ) : null + } + > + {confirmAction ? ( + + {confirmMeta[confirmAction.kind].message} + {confirmAction.kind === "add" && + capacity > 0 && + used + (confirmAction.weightTons ?? 0) > capacity ? ( + + + + This add pushes the heaviest leg past the locomotive pull weight ( + {(used + (confirmAction.weightTons ?? 0)).toFixed(1)}T / {capacity.toFixed(0)}T). + + + ) : null} + + + + + + ) : null} +
); } diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx index 4c1185cb2..4257e37b2 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx @@ -39,11 +39,6 @@ interface InteractiveTrainConsistProps { onMoveLoad?: (move: WagonLoadMove) => void; } -const wagonItems = (wagon: Wagon) => - (wagon.allocations ?? []) - .flatMap((a) => a.containerItems ?? []) - .sort((a, b) => (a.positionOnWagon ?? 99) - (b.positionOnWagon ?? 99)); - const CONTAINER_GRADIENTS = [ "linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))", "linear-gradient(180deg, var(--mantine-color-blue-5), var(--mantine-color-blue-7))", @@ -192,7 +187,31 @@ function WagonCar({ }) { const [dropHover, setDropHover] = useState(false); const wagon = slots[0]!; - const loaded = slots.filter((s) => (s.allocations?.length ?? 0) > 0); + const loadedSlots = slots.filter((s) => (s.allocations?.length ?? 0) > 0); + // One drawn row per LOAD, not per slot. A wagon reused across disjoint legs + // (containers to Dire Dawa, bulk onward) is ONE slot holding two allocations + // with different corridors — counting slots drew that as a single row and + // hid the second load entirely. Group the slot's allocations by their own + // booking corridor so each load gets its own row, stacked top/bottom. + const loaded = loadedSlots.flatMap((slot) => { + const allocations = slot.allocations ?? []; + const byCorridor = new Map(); + for (const allocation of allocations) { + const key = + allocation.originYardId && allocation.destinationYardId + ? `${allocation.originYardId}->${allocation.destinationYardId}` + : "whole-route"; + byCorridor.set(key, [...(byCorridor.get(key) ?? []), allocation]); + } + if (byCorridor.size < 2) { + return [{ slot, allocations, corridorKey: null as string | null }]; + } + return [...byCorridor.entries()].map(([key, group]) => ({ + slot, + allocations: group, + corridorKey: key as string | null, + })); + }); const shared = loaded.length > 1; const isEmpty = !loaded.length; const isBulk = loaded.some((s) => @@ -201,12 +220,15 @@ function WagonCar({ // GROSS on both sides: cargo across every slot + tare (counted ONCE — the // slots share the same physical wagon) vs rated payload + tare. const tare = wagon.tareWeightTons ?? 0; + // Heaviest single load, not the sum: rows on disjoint legs never ride at the + // same time, so summing them would over-report what the wagon carries. const cargo = loaded.reduce( - (sum, s) => - sum + - ((s.allocations ?? []).reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0) || - s.assignedWeightTons || - 0), + (max, row) => + Math.max( + max, + row.allocations.reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0) || + (loaded.length === 1 ? row.slot.assignedWeightTons || 0 : 0), + ), 0, ); const assigned = cargo + tare; @@ -249,8 +271,8 @@ function WagonCar({ onSelectSlot(loaded[0] ?? wagon)} - style={{ width: 120, flexShrink: 0, cursor: "pointer" }} + onClick={() => onSelectSlot(loaded[0]?.slot ?? wagon)} + style={{ width: 148, flexShrink: 0, cursor: "pointer" }} > { @@ -270,7 +292,9 @@ function WagonCar({ }} style={{ position: "relative", - height: 70, + // A leg-sharing wagon stacks its loads (bulk and container rows + // top/bottom) — give the stack real height so both stay legible. + height: shared ? 88 : 70, borderRadius: 11, background: isEmpty ? "var(--mantine-color-gray-0)" @@ -387,16 +411,25 @@ function WagonCar({ // two side by side. A shared wagon stacks its slots top/bottom // (intercity above, export below); each row selects ITS slot. - {loaded.map((slot, r) => { - const rowBulk = (slot.allocations ?? []).some((a) => + {loaded.map((row, r) => { + const slot = row.slot; + const rowBulk = row.allocations.some((a) => (a.loadType ?? "").toUpperCase().includes("BULK"), ); - const rowBlocks = wagonItems(slot).slice(0, 2); + // Container blocks of THIS row's allocations only, so a + // leg-shared wagon shows each leg's own boxes. + const rowBlocks = row.allocations + .flatMap((a) => a.containerItems ?? []) + .slice() + .sort( + (a, b) => (a.positionOnWagon ?? 0) - (b.positionOnWagon ?? 0), + ) + .slice(0, 2); const rowSelected = shared && slot.id === selectedWagonId; - const rowHeight = shared ? 13 : 26; + const rowHeight = shared ? 20 : 26; return ( ) : ( - {loaded.map((slot) => { - const slotAllocation = slot.allocations?.[0]; + {loaded.map((row) => { + const slot = row.slot; + const slotAllocation = row.allocations[0]; const slotCompany = getCompany(slotAllocation?.bookingId); - const slotContainers = wagonItems(slot).map( - (c) => c.containerNumber?.trim() || "—", - ); + // This row's own containers, so a leg-shared wagon lists each + // leg's boxes under its own load rather than all of them twice. + const slotContainers = row.allocations + .flatMap((a) => a.containerItems ?? []) + .map((c) => c.containerNumber?.trim() || "—"); return ( void }) { const { toast } = useToast(); + const { user } = useAuth(); + const canLoad = hasFreightPermission(user, FREIGHT_PERMS.warehouseInventory.load); const { data: rows = [], isLoading } = useQuery( api.warehouses.readyToLoadExport.queryOptions({ enabled }), ); @@ -1655,16 +1659,18 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: <>{controls.filteredRows.length} item{controls.filteredRows.length !== 1 ? 's' : ''} ready to load )} - + + + void; }) { const { toast } = useToast(); + const { user } = useAuth(); + const canUnload = hasFreightPermission(user, FREIGHT_PERMS.warehouseInventory.unload); const { data: trains = [], isLoading } = useQuery( api.warehouses.importArriveQueue.queryOptions({ enabled }), ); @@ -2489,23 +2497,25 @@ export function ImportArriveQueueTab({ > Open - + + +
diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 7f6f4993c..3f1cce1b5 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -235,6 +235,7 @@ export const QUERY_KEYS = { OVERVIEW: { ROOT: ["overview"] as const, + layouts: () => ["overview", "layouts"] as const, dashboard: (range?: string) => ["overview", "dashboard", range ?? "30d"] as const, bookingsTab: (range?: string) => diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 68fefa3c9..bb3d88d2a 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -184,6 +184,7 @@ export const URL_CONSTANTS = { OVERVIEW: { BASE: "/overview", + LAYOUTS: "/overview/layouts", BOOKINGS: "/overview/bookings", CONTRACTS: "/overview/contracts", BILLING: "/overview/billing", diff --git a/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts b/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts index 7225ee5bc..42abe59e1 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts @@ -11,6 +11,15 @@ export function useOverview(range: OverviewRange = "30d") { }); } +/** Layouts the caller may render — server-filtered by permission, same shape as useReports' catalog. */ +export function useOverviewLayouts() { + return useQuery({ + queryKey: QUERY_KEYS.OVERVIEW.layouts(), + queryFn: () => overviewService.getLayouts(), + staleTime: 5 * 60 * 1000, + }); +} + export function useOverviewBookingsTab(range: OverviewRange, enabled: boolean) { return useQuery({ queryKey: QUERY_KEYS.OVERVIEW.bookingsTab(range), diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 177976b28..d8ef47f35 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -104,6 +104,9 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:train_scheduling:view", create: "edr_freight_app:train_scheduling:create", update: "edr_freight_app:train_scheduling:update", + /** Confirm cargo loaded/unloaded at a yard — import, export, and intercity alike. */ + load: "edr_freight_app:train_scheduling:load", + unload: "edr_freight_app:train_scheduling:unload", cancel: "edr_freight_app:train_scheduling:cancel", reschedule: "edr_freight_app:train_scheduling:reschedule", rulesManage: "edr_freight_app:train_scheduling:rules_manage", diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index 6d2787d4f..758f8a7bb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -1,19 +1,12 @@ import { useMyTradeAccess } from "@/hooks/useMyTradeAccess"; -import { - Box, - Button, - Card, - Group, - Modal, - Stack, - Text, -} from "@mantine/core"; +import { Box, Button, Card, Group, Modal, Stack, Text } from "@mantine/core"; import { AlertTriangle, ArrowRight, Calendar, CheckCircle2, Clock, + FileText, LayoutList, Link2, Package, @@ -23,13 +16,19 @@ import { User, } from "lucide-react"; import { useCallback, useMemo, useRef, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu"; import { ExportButton } from "@/components/export/ExportButton"; import { formatDate, humanize } from "@/lib/format"; -import { FilterBar, dateRangeParams, routeParams, useFilters, type FilterDef } from "@/components/filters"; +import { + FilterBar, + dateRangeParams, + routeParams, + useFilters, + type FilterDef, +} from "@/components/filters"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; // BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs. @@ -150,36 +149,97 @@ export default function BookingRequestsPage() { // split), so a deep link can never land behind "More filters" unseen. const bookingFilterDefs: FilterDef[] = useMemo( () => [ - { key: "customerKind", label: "Booked by", type: "enum", multiple: false, options: CUSTOMER_KIND_OPTIONS }, - { key: "bookingType", label: "Kind", type: "enum", multiple: false, options: BOOKING_KIND_OPTIONS }, - { key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS }, { - key: "tradeDirection", label: "Direction", type: "enum", multiple: false, + key: "customerKind", + label: "Booked by", + type: "enum", + multiple: false, + options: CUSTOMER_KIND_OPTIONS, + }, + { + key: "bookingType", + label: "Kind", + type: "enum", + multiple: false, + options: BOOKING_KIND_OPTIONS, + }, + { + key: "statuses", + label: "Status", + type: "enum", + options: STATUS_OPTIONS, + }, + { + key: "tradeDirection", + label: "Direction", + type: "enum", + multiple: false, options: filterOptions(TRADE_DIRECTION_OPTIONS), }, - { key: "freightType", label: "Freight", type: "enum", multiple: false, options: FREIGHT_TYPE_OPTIONS }, - { key: "serviceTypeId", label: "Service", type: "enum", multiple: false, options: serviceTypeOptions }, - { key: "paymentStatus", label: "Payment", type: "enum", multiple: false, options: PAYMENT_STATUS_OPTIONS, secondary: true }, + { + key: "freightType", + label: "Freight", + type: "enum", + multiple: false, + options: FREIGHT_TYPE_OPTIONS, + }, + { + key: "serviceTypeId", + label: "Service", + type: "enum", + multiple: false, + options: serviceTypeOptions, + }, + { + key: "paymentStatus", + label: "Payment", + type: "enum", + multiple: false, + options: PAYMENT_STATUS_OPTIONS, + secondary: true, + }, { // Wins over the `paymentStatus` filter above — the queue is by // definition PAID — because it's later in this array: toApiParams // merges defs in order, so a later toParams overwrites an earlier one. - key: "paidUnallocated", label: "Allocation", type: "boolean", secondary: true, + key: "paidUnallocated", + label: "Allocation", + type: "boolean", + secondary: true, trueLabel: "Paid, not allocated", - toParams: (v) => (v.v[0] === "true" ? { paymentStatus: "PAID", assignedToSchedule: "false" } : {}), + toParams: (v) => + v.v[0] === "true" + ? { paymentStatus: "PAID", assignedToSchedule: "false" } + : {}, }, - { key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS, secondary: true }, { - key: "route", label: "Route", type: "route", options: yardOptions, + key: "isGovernment", + label: "Ownership", + type: "enum", + multiple: false, + options: OWNERSHIP_OPTIONS, + secondary: true, + }, + { + key: "route", + label: "Route", + type: "route", + options: yardOptions, toParams: routeParams("originYardId", "destinationYardId"), }, { - key: "created", label: "Created", type: "date", secondary: true, + key: "created", + label: "Created", + type: "date", + secondary: true, operators: ["between", "before", "after"], toParams: dateRangeParams("createdFrom", "createdTo"), }, { - key: "scheduled", label: "Scheduled", type: "date", secondary: true, + key: "scheduled", + label: "Scheduled", + type: "date", + secondary: true, operators: ["between", "before", "after"], toParams: dateRangeParams("scheduledFrom", "scheduledTo"), }, @@ -187,19 +247,24 @@ export default function BookingRequestsPage() { [filterOptions, yardOptions, serviceTypeOptions], ); - const controls = useFilters(bookingFilterDefs, { defaultSort: "createdAt:DESC", pageSize: 10 }); + const controls = useFilters(bookingFilterDefs, { + defaultSort: "createdAt:DESC", + pageSize: 10, + }); const filter: BookingListFilter = useMemo( () => ({ ...(controls.params as unknown as BookingListFilter), // React Query cache key per kind selection ("ALL" when unfiltered) — // kept as a param the API ignores, matching the pre-migration cache key. - tab: (controls.values.bookingType?.v[0] as BookingKind | undefined) ?? "ALL", + tab: + (controls.values.bookingType?.v[0] as BookingKind | undefined) ?? "ALL", }), [controls.params, controls.values.bookingType], ); - const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter); + const { data, isLoading, isError, refetch, isFetching } = + useBookingList(filter); const primaryAllocateId = allocateIds[0]; const { data: allocateBooking } = useBookingDetail( allocateOpen ? primaryAllocateId : undefined, @@ -262,8 +327,9 @@ export default function BookingRequestsPage() { async (row: BookingListRow) => { setAllocatingId(row.id); try { - const candidates = - await trainSchedulingService.getAllocationCandidates(row.id); + const candidates = await trainSchedulingService.getAllocationCandidates( + row.id, + ); if (candidates.sameDay.length > 0) { const target = candidates.sameDay[0]; await trainSchedulingService.allocatePaidBooking(row.id, target.id); @@ -330,7 +396,9 @@ export default function BookingRequestsPage() {
-

{b.reference}

+

+ {b.reference} +

+ {b.contractReference ? ( +

+ + {b.contractId ? ( + e.stopPropagation()} + className="truncate text-blue-600 hover:underline" + > + {b.contractReference} + + ) : ( + + {b.contractReference} + + )} +

+ ) : null}

{b.isShippingLine ? ( @@ -346,7 +434,10 @@ export default function BookingRequestsPage() { )} {b.customerLabel} {b.isShippingLine ? ( - + Shipping line ) : null} @@ -382,7 +473,9 @@ export default function BookingRequestsPage() {

{b.originLabel} - {b.destinationLabel} + + {b.destinationLabel} +
{ const b = row.original; - const needsAllocation = b.paymentStatus === "PAID" && !b.trainScheduleId; + const needsAllocation = + b.paymentStatus === "PAID" && !b.trainScheduleId; return ( {needsAllocation ? ( @@ -474,61 +568,61 @@ export default function BookingRequestsPage() { return ( - - - - - } - /> + + + + + } + /> - + - {/* Status tabs replaced by booking-kind tabs (one-time / general). The + {/* Status tabs replaced by booking-kind tabs (one-time / general). The old BookingStatusTabs is commented out — status is now a filter select. */} - - - - - - + + + + + + + + + {showEmpty ? ( + + - - {showEmpty ? ( - - - - ) : ( - - - - )} - - - - - setOtherDayModal(null)} - title="Allocate to another date" - centered - > - - - No train on {otherDayModal ? formatDate(otherDayModal.booking.scheduledDate) : "the booking's day"}{" "} - fits booking {otherDayModal?.booking.reference}. These trains on - other dates do — the customer will be notified of the date change. - - {otherDayModal?.candidates.map((c) => ( - -
- - {c.reference ?? "Train"} - - - Departs {formatDate(c.scheduledDepartureDate)} - {c.direction ? ` · ${c.direction}` : ""} - -
- -
- ))} + ) : ( + + + + )}
-
+
+
- {allocateBooking ? ( - { - setAllocateOpen(false); - setAllocateIds([]); - void refetch(); - }} - initialBookingIds={allocateIds} - /> - ) : null} + setOtherDayModal(null)} + title="Allocate to another date" + centered + > + + + No train on{" "} + {otherDayModal + ? formatDate(otherDayModal.booking.scheduledDate) + : "the booking's day"}{" "} + fits booking {otherDayModal?.booking.reference}. These trains on + other dates do — the customer will be notified of the date change. + + {otherDayModal?.candidates.map((c) => ( + +
+ + {c.reference ?? "Train"} + + + Departs {formatDate(c.scheduledDepartureDate)} + {c.direction ? ` · ${c.direction}` : ""} + +
+ +
+ ))} +
+
+ + {allocateBooking ? ( + { + setAllocateOpen(false); + setAllocateIds([]); + void refetch(); + }} + initialBookingIds={allocateIds} + /> + ) : null}
); } diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/ConsolidationApprovalsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/ConsolidationApprovalsPage.tsx index 1aebe90c3..ac1c120a2 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/ConsolidationApprovalsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/ConsolidationApprovalsPage.tsx @@ -10,13 +10,23 @@ import { Group, Loader, Modal, + Pagination, Paper, Stack, + Tabs, Text, Textarea, ThemeIcon, } from "@mantine/core"; -import { AlertCircle, Check, Clock, Link2, X } from "lucide-react"; +import { + AlertCircle, + Check, + Clock, + FileText, + Link2, + User, + X, +} from "lucide-react"; import toast from "react-hot-toast"; import { PageContainer, PageHeader } from "@/components/page"; @@ -28,6 +38,39 @@ import { formatDateTime } from "@/lib/format"; import { extractErrorMessage } from "@/utils/errorExtractor"; const QUEUE_KEY = ["consolidation-approvals", "queue"]; +const PAGE_SIZE = 10; + +type Status = ConsolidationApprovalRow["status"]; + +const TABS: { value: Status; label: string }[] = [ + { value: "PENDING", label: "Awaiting approval" }, + { value: "APPROVED", label: "Approved" }, + { value: "REJECTED", label: "Rejected" }, +]; + +const STATUS_COLOR: Record = { + PENDING: "yellow", + APPROVED: "green", + REJECTED: "red", +}; + +const STATUS_LABEL: Record = { + PENDING: "Awaiting approval", + APPROVED: "Approved", + REJECTED: "Rejected", +}; + +const STATUS_VERB: Record = { + PENDING: "", + APPROVED: "Approved by", + REJECTED: "Rejected by", +}; + +const EMPTY_TEXT: Record = { + PENDING: "Nothing waiting for approval.", + APPROVED: "No shared wagon has been approved yet.", + REJECTED: "No shared wagon has been rejected.", +}; /** * Review queue for shared-wagon pairings. @@ -37,6 +80,11 @@ const QUEUE_KEY = ["consolidation-approvals", "queue"]; * under two separate invoices, so a person signs off on the pairing first. * Approving releases BOTH bookings to Operations; rejecting sends BOTH back to * GL with the reason. + * + * Decided pairings stay on the page rather than vanishing: the decided tabs are + * the record of who signed off on which wagon and why. A rejection is not final + * either — a rejected pairing can still be approved from here once whatever + * blocked it is settled. */ export default function ConsolidationApprovalsPage() { const qc = useQueryClient(); @@ -45,16 +93,31 @@ export default function ConsolidationApprovalsPage() { kind: "approve" | "reject"; } | null>(null); const [note, setNote] = useState(""); + const [tab, setTab] = useState("PENDING"); + const [page, setPage] = useState(1); - const { - data: rows, - isLoading, - isError, - } = useQuery({ - queryKey: QUEUE_KEY, - queryFn: () => bookingsService.consolidationApprovalQueue(), + const { data, isLoading, isError, isFetching } = useQuery({ + queryKey: [...QUEUE_KEY, tab, page], + queryFn: () => + bookingsService.consolidationApprovalQueue({ + status: tab, + page, + pageSize: PAGE_SIZE, + }), + // Keeping the last page on screen while the next one loads stops the list + // from collapsing to a spinner on every page or tab click. + placeholderData: (previous) => previous, }); + const shown = data?.items ?? []; + const pageCount = Math.max(1, data?.meta.totalPages ?? 1); + const countOf = (status: Status) => data?.counts?.[status] ?? 0; + + const goToTab = (next: Status) => { + setTab(next); + setPage(1); + }; + const close = () => { setDecision(null); setNote(""); @@ -64,7 +127,10 @@ export default function ConsolidationApprovalsPage() { mutationFn: () => { if (!decision) throw new Error("No pairing selected"); return decision.kind === "approve" - ? bookingsService.approveConsolidation(decision.row.id, note.trim() || undefined) + ? bookingsService.approveConsolidation( + decision.row.id, + note.trim() || undefined, + ) : bookingsService.rejectConsolidation(decision.row.id, note.trim()); }, onSuccess: () => { @@ -73,6 +139,7 @@ export default function ConsolidationApprovalsPage() { ? "Shared wagon approved — both bookings sent to Operations" : "Shared wagon rejected — both bookings returned to GL", ); + goToTab(decision?.kind === "approve" ? "APPROVED" : "REJECTED"); void qc.invalidateQueries({ queryKey: QUEUE_KEY }); close(); }, @@ -99,90 +166,194 @@ export default function ConsolidationApprovalsPage() { }> Could not load the approval queue. - ) : !rows?.length ? ( - }> - Nothing waiting for approval. - ) : ( - - {rows.map((row) => ( - - - - - - - - - Shared wagon - - - Awaiting approval - - - - - - - - - - - - Requested {formatDateTime(row.requestedAt)} - {row.scheduledDate - ? ` · ships ${formatDateTime(row.scheduledDate)}` - : ""} - - - - - - - + {countOf(value)} +
+ } + > + {label} + + ))} + + + {!shown.length ? ( + }> + {EMPTY_TEXT[tab]} + + ) : ( + + {shown.map((row) => ( + + + + + + + + + Shared wagon + + + {STATUS_LABEL[row.status]} + + + + + + + + + + + + Requested {formatDateTime(row.requestedAt)} + {row.requestedByName + ? ` by ${row.requestedByName}` + : ""} + {row.scheduledDate + ? ` · ships ${formatDateTime(row.scheduledDate)}` + : ""} + + + + {row.status !== "PENDING" && ( + + + + + {STATUS_VERB[row.status]}{" "} + {row.decidedByName ?? "an unknown user"} + {row.decidedAt + ? ` on ${formatDateTime(row.decidedAt)}` + : ""} + + {row.decisionNote && ( + + “{row.decisionNote}” + + )} + + + )} + + + {row.status !== "APPROVED" && ( + + + {row.status === "PENDING" && ( + + )} + + )} + + + ))} + + {pageCount > 1 && ( + + + Showing {(page - 1) * PAGE_SIZE + 1}– + {Math.min(page * PAGE_SIZE, data?.total ?? 0)} of{" "} + {data?.total ?? 0} + + - - - ))} - + )} + + )} + )} - {decision?.kind === "approve" - ? "Approve this shared wagon?" - : "Reject this shared wagon?"} + {decision?.kind !== "approve" + ? "Reject this shared wagon?" + : decision.row.status === "REJECTED" + ? "Approve this rejected shared wagon?" + : "Approve this shared wagon?"} } > - {decision?.kind === "approve" - ? "Both bookings leave the gate together and continue to Operations. Each is still invoiced and paid separately." - : "Both bookings go back to GL as “changes requested” with your reason. Neither reaches Operations."} + {decision?.kind !== "approve" + ? "Both bookings go back to GL as “changes requested” with your reason. Neither reaches Operations." + : decision.row.status === "REJECTED" + ? "This pairing was rejected before. Approving it now overrides that decision — both bookings leave the gate together and continue to Operations." + : "Both bookings leave the gate together and continue to Operations. Each is still invoiced and paid separately."}