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/.env.example b/apps/edr-freight-api/.env.example index 930c8f1ca..8c40ac952 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -202,14 +202,13 @@ EIMS_NATURE_OF_SUPPLIES=service EIMS_PAYMENT_MODE=CASH EIMS_PAYMENT_TERM=IMMIDIATE EIMS_UNIT_DEFAULT=PCS -# MoR numeric country code for the buyer; our companies store the country name. -EIMS_BUYER_COUNTRY_CODE= -# Buyer region name -> MoR numeric code. companies.region holds names; MoR wants ^[0-9]{1,3}$. -# An unmapped region fails locally rather than being filed with a guess. -EIMS_BUYER_REGION_CODES=Addis Ababa=13 -# Same mechanism for Wereda. MoR has never named a Wereda regex in an error (only Region's is -# confirmed), so this is precautionary — but an unmapped name still fails locally, not filed as a guess. -EIMS_BUYER_WEREDA_CODES= +# Buyer Country/Region/City/Wereda are NOT configured here any more. They are resolved from the +# Ministry's own location master (EIMS_COUNTRY_REGION_VW), committed as +# src/config/mor-locations.data.ts and regenerated with: +# pnpm --filter @edr/freight-api eims:import-locations +# The removed EIMS_BUYER_COUNTRY_CODE / _COUNTRY_CODES / _REGION_CODES / _CITY_CODES / +# _WEREDA_CODES maps are ignored if still set — MoR reference data is the only source, and an env +# var must not be able to override an official code. Delete them from your deployment config. EIMS_CASHIER_NAME= EIMS_SALESPERSON_NAME= # Automatic filing of issued invoices (@Cron sweep, one invoice per tick). diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index e10e5dcb7..0c6b1c727 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -38,7 +38,8 @@ "iam:migration:show": "pnpm run iam:typeorm:cli migration:show", "migration:run": "nest build && node dist/scripts/migrate.js", "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts", - "eims:login": "ts-node -r tsconfig-paths/register src/scripts/eims-login.ts" + "eims:login": "ts-node -r tsconfig-paths/register src/scripts/eims-login.ts", + "eims:import-locations": "ts-node -r tsconfig-paths/register src/scripts/import-mor-locations.ts" }, "dependencies": { "@edr/api-common": "workspace:*", 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/config/eims.config.spec.ts b/apps/edr-freight-api/src/config/eims.config.spec.ts index 127b3ea62..a6aa3895d 100644 --- a/apps/edr-freight-api/src/config/eims.config.spec.ts +++ b/apps/edr-freight-api/src/config/eims.config.spec.ts @@ -70,62 +70,3 @@ describe("eims.config — private key / certificate resolution", () => { ); }); }); - -describe("eims.config — baked-in Ethiopia region/zone/woreda codes", () => { - it("resolves a known region/wereda/zone with no env var set at all", () => { - withEnv( - { ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" }, - () => { - const cfg = eimsConfigFactory(); - expect(cfg.invoice.buyerRegionCodes.Somali).toBe("05"); - expect(cfg.invoice.buyerWeredaCodes["Jijiga Town"]).toBe("02"); - expect(cfg.invoice.buyerCityCodes.Fafan).toBe("01"); - }, - ); - }); - - it("an env var entry overrides the baked-in code for the same name", () => { - withEnv( - { - ...REQUIRED, - EIMS_PRIVATE_KEY: "x", - EIMS_CERTIFICATE_PATH: "/dev/null", - EIMS_BUYER_REGION_CODES: "Somali=99", - }, - () => { - expect(eimsConfigFactory().invoice.buyerRegionCodes.Somali).toBe("99"); - }, - ); - }); - - it("an env var still adds a name the baked-in table doesn't have (a spelling variant)", () => { - withEnv( - { - ...REQUIRED, - EIMS_PRIVATE_KEY: "x", - EIMS_CERTIFICATE_PATH: "/dev/null", - EIMS_BUYER_CITY_CODES: "Fafen=01", - }, - () => { - const codes = eimsConfigFactory().invoice.buyerCityCodes; - expect(codes.Fafen).toBe("01"); - expect(codes.Fafan).toBe("01"); // baked-in entry still present alongside it - }, - ); - }); - - it("resolves the bare Addis Ababa sub-city name a buyer profile actually stores, not the CSV's example-woreda name", () => { - withEnv( - { ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" }, - () => { - const codes = eimsConfigFactory().invoice.buyerWeredaCodes; - expect(codes.Bole).toBe("01"); - expect(codes.Arada).toBe("01"); - expect(codes.Kirkos).toBe("01"); - expect(codes.Yeka).toBe("01"); - expect(codes["Nifas Silk Lafto"]).toBe("13"); - expect(codes["Nefas Silk-Lafto"]).toBe("13"); - }, - ); - }); -}); diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index e5530eaf2..e0140ba68 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -1,6 +1,5 @@ import { registerAs } from "@nestjs/config"; -import { ETHIOPIA_REGION_CODES, ETHIOPIA_WOREDA_CODES, ETHIOPIA_ZONE_CODES } from "./ethiopia-geo-codes"; /** * Ethiopian MoR EIMS e-invoicing gateway. @@ -99,35 +98,6 @@ export interface EimsInvoiceConfig { paymentMode: string; paymentTerm: string; unitDefault: string; - /** - * Domestic fallback only — used when the buyer's `Company.country` is empty or "Ethiopia" (the - * column's own default) and not already listed in `buyerCountryCodes`. A genuinely foreign - * buyer must be in `buyerCountryCodes` by name or the mapping fails locally; this value is never - * applied to them, so an unconfigured foreign country can't silently be filed as Ethiopia. - */ - buyerCountryCode: string | null; - /** - * Country name → MoR code, from `EIMS_BUYER_COUNTRY_CODES` ("Ethiopia=231,Djibouti=071"). Format - * unconfirmed (unlike Region/Wereda, MoR has never named a Country regex), so — unlike them — - * this is not validated against a fixed digit pattern, only looked up by name. - */ - buyerCountryCodes: Record; - /** - * Buyer region name → MoR numeric code, from `EIMS_BUYER_REGION_CODES` - * ("Addis Ababa=13,Oromia=4"). A buyer whose region is neither a code nor in this map fails - * locally rather than being filed with a guessed one. - */ - buyerRegionCodes: Record; - /** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */ - buyerWeredaCodes: Record; - /** - * Buyer *zone* name → MoR City code, from `EIMS_BUYER_CITY_CODES` ("Kirkos=101"). `Company` has - * no dedicated city column — Zone is the closest match in EDR's own data. Optional, unlike - * Region/Wereda: MoR has never required City on a live buyer (confirmed — filing already - * succeeds with it null), so an unmapped zone falls back to null rather than failing the - * mapping. - */ - buyerCityCodes: Record; /** * Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` + * `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to @@ -258,13 +228,6 @@ export default registerAs("eims", (): EimsConfig => { paymentMode: process.env.EIMS_PAYMENT_MODE ?? "", paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "", unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "", - buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null, - buyerCountryCodes: parseCodeMap(process.env.EIMS_BUYER_COUNTRY_CODES), - // Baked-in Ethiopia reference table first, env var entries win on a name collision — lets a - // deployment override or add to it without a redeploy. See ethiopia-geo-codes.ts. - buyerRegionCodes: { ...ETHIOPIA_REGION_CODES, ...parseCodeMap(process.env.EIMS_BUYER_REGION_CODES) }, - buyerWeredaCodes: { ...ETHIOPIA_WOREDA_CODES, ...parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES) }, - buyerCityCodes: { ...ETHIOPIA_ZONE_CODES, ...parseCodeMap(process.env.EIMS_BUYER_CITY_CODES) }, taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE), taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE), exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE), diff --git a/apps/edr-freight-api/src/config/ethiopia-geo-codes.ts b/apps/edr-freight-api/src/config/ethiopia-geo-codes.ts deleted file mode 100644 index ca48a7c5f..000000000 --- a/apps/edr-freight-api/src/config/ethiopia-geo-codes.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * MoR EIMS region/zone/woreda codes, by name — the baked-in fallback under - * `EIMS_BUYER_REGION_CODES`/`EIMS_BUYER_WEREDA_CODES`/`EIMS_BUYER_CITY_CODES` (zone is the closest - * match to EIMS's "City", per `eims-invoice.mapper.ts`). - * - * Before this existed, every buyer from a not-yet-seen region/zone/woreda crashed EIMS filing until - * someone hunted down the code and added it to an env var by hand — happened three times in one - * afternoon (2026-08-17: Somali region, Fafan zone, Jigjiga woreda, even the Ethiopia country code - * itself were all unset). Ethiopia's administrative divisions are fixed, known, reference data, not - * something that should be maintained reactively per buyer. Source: `ethiopia_administrative_ - * hierarchy_master.csv`, supplied 2026-08-17 — NOT exhaustive (a representative sample per region, - * not all ~1000 real woredas), extend as new gaps surface. - * - * The env vars stay wired in ahead of this table (see `eims.config.ts`) — for a quick correction - * without a redeploy, or a name spelled differently in a buyer's profile than in this table (already - * hit live: DB has zone "Fafen", this table's official spelling is "Fafan" — same zone, matching is - * case/space-insensitive but not spelling-tolerant, so the env var override is still how that buyer - * actually resolves; this table mainly helps the *next* buyer whose profile spelling matches). - * - * ponytail: region names are unique nationwide (only ~15), safe as a flat map. Zone and woreda names - * are not always unique across different regions (e.g. "North Shewa" is both an Amhara zone and an - * Oromia zone, different codes) — `Company` stores region/zone/woreda as three independent strings, - * no parent linkage, so a flat name lookup can't disambiguate. First occurrence in the source data - * wins on a collision. Only affects the optional `City` field (zone) — never blocks filing, unlike - * Region/Wereda. A correct fix needs `Company` to store a linked hierarchy, not just three strings; - * out of scope here. Upgrade path: key this by `${region}/${zone}` once that linkage exists. - */ -const ROWS: Array<[region: string, zone: string, woreda: string, regionCode: string, zoneCode: string, woredaCode: string]> = [ - ["Tigray", "Western Tigray", "Humera", "01", "01", "01"], - ["Tigray", "Western Tigray", "Kafta Humera", "01", "01", "02"], - ["Tigray", "Western Tigray", "Tsegede", "01", "01", "03"], - ["Tigray", "North Western Tigray", "Shire Endaselassie", "01", "02", "01"], - ["Tigray", "North Western Tigray", "Sheraro", "01", "02", "02"], - ["Tigray", "Central Tigray", "Axum", "01", "03", "01"], - ["Tigray", "Central Tigray", "Adwa", "01", "03", "02"], - ["Tigray", "Eastern Tigray", "Adigrat", "01", "04", "01"], - ["Tigray", "Southern Tigray", "Maychew", "01", "05", "01"], - ["Tigray", "Mekelle Special Zone", "Mekelle City", "01", "06", "01"], - ["Afar", "Awusi Rasu (Zone 1)", "Asayita", "02", "01", "01"], - ["Afar", "Awusi Rasu (Zone 1)", "Semera-Logiya", "02", "01", "02"], - ["Afar", "Kilbet Rasu (Zone 2)", "Abala", "02", "02", "01"], - ["Afar", "Gabi Rasu (Zone 3)", "Awash Fentale", "02", "03", "01"], - ["Afar", "Fantena Rasu (Zone 4)", "Yalo", "02", "04", "01"], - ["Afar", "Hari Rasu (Zone 5)", "Telalak", "02", "05", "01"], - ["Amhara", "North Gondar", "Debark", "03", "01", "01"], - ["Amhara", "South Gondar", "Debre Tabor", "03", "02", "01"], - ["Amhara", "North Wollo", "Woldiya", "03", "03", "01"], - ["Amhara", "South Wollo", "Dessie Town", "03", "04", "01"], - ["Amhara", "North Shewa", "Debre Berhan", "03", "05", "01"], - ["Amhara", "East Gojjam", "Debre Markos", "03", "06", "01"], - ["Amhara", "West Gojjam", "Finote Selam", "03", "07", "01"], - ["Amhara", "Wag Hemra", "Sekota", "03", "08", "01"], - ["Amhara", "Awi", "Injibara", "03", "09", "01"], - ["Amhara", "Oromia Special Zone", "Kemise", "03", "10", "01"], - ["Amhara", "Bahir Dar Special Zone", "Bahir Dar City", "03", "11", "01"], - ["Amhara", "Gondar Special Zone", "Gondar City", "03", "12", "01"], - ["Oromia", "North Shewa", "Fiche", "04", "01", "01"], - ["Oromia", "South West Shewa", "Waliso", "04", "02", "01"], - ["Oromia", "East Shewa", "Adama Town", "04", "03", "01"], - ["Oromia", "East Shewa", "Bishoftu Town", "04", "03", "02"], - ["Oromia", "West Shewa", "Ambo", "04", "04", "01"], - ["Oromia", "Arsi", "Asella", "04", "05", "01"], - ["Oromia", "West Arsi", "Shashemene", "04", "06", "01"], - ["Oromia", "Bale", "Robe", "04", "07", "01"], - ["Oromia", "East Hararghe", "Harar Outskirts", "04", "08", "01"], - ["Oromia", "West Hararghe", "Chiro", "04", "09", "01"], - ["Oromia", "Jimma", "Jimma Town", "04", "10", "01"], - ["Oromia", "Illubabor", "Mettu", "04", "11", "01"], - ["Oromia", "Buno Bedele", "Bedele", "04", "12", "01"], - ["Oromia", "Welega (West)", "Gimbi", "04", "13", "01"], - ["Oromia", "Welega (East)", "Nekemte", "04", "14", "01"], - ["Oromia", "Horo Guduru Welega", "Shambu", "04", "15", "01"], - ["Oromia", "Kelam Welega", "Dembidolo", "04", "16", "01"], - ["Oromia", "Borena", "Yabelo", "04", "17", "01"], - ["Oromia", "Guji", "Negele Borana", "04", "18", "01"], - ["Oromia", "West Guji", "Bule Hora", "04", "19", "01"], - ["Oromia", "East Bale", "Ginir", "04", "20", "01"], - ["Oromia", "Sheger City", "Sululta", "04", "21", "01"], - ["Somali", "Fafan", "Jijiga Woreda", "05", "01", "01"], - ["Somali", "Fafan", "Jijiga Town", "05", "01", "02"], - ["Somali", "Fafan", "Awbare", "05", "01", "03"], - ["Somali", "Sitti", "Shinile", "05", "02", "01"], - ["Somali", "Erer", "Fiq", "05", "03", "01"], - ["Somali", "Jarar", "Degehabur", "05", "04", "01"], - ["Somali", "Nogob", "Segeg", "05", "05", "01"], - ["Somali", "Korahe", "Kebridehar", "05", "06", "01"], - ["Somali", "Shabelle", "Gode", "05", "07", "01"], - ["Somali", "Afder", "Afder Woreda", "05", "08", "01"], - ["Somali", "Liben", "Filtu", "05", "09", "01"], - ["Somali", "Dhawa", "Mubarak", "05", "10", "01"], - ["Somali", "Dollo", "Warder", "05", "11", "01"], - ["Benishangul-Gumuz", "Asosa", "Asosa Woreda", "06", "01", "01"], - ["Benishangul-Gumuz", "Kamasashi", "Kamasashi Woreda", "06", "02", "01"], - ["Benishangul-Gumuz", "Metekel", "Gilgel Beles", "06", "03", "01"], - ["Southern Ethiopia", "Wolayta", "Sodo Zuria", "07", "01", "01"], - ["Southern Ethiopia", "Wolayta", "Sodo Town", "07", "01", "02"], - ["Southern Ethiopia", "Gamo", "Arba Minch Town", "07", "02", "01"], - ["Southern Ethiopia", "Gofa", "Sawla", "07", "03", "01"], - ["Southern Ethiopia", "Konso", "Konso Woreda", "07", "04", "01"], - ["Southern Ethiopia", "South Omo", "Jinka", "07", "05", "01"], - ["Gambela", "Anywaa", "Gambela Zuria", "08", "01", "01"], - ["Gambela", "Nuer", "Lare", "08", "02", "01"], - ["Gambela", "Majang", "Metu Zuria part", "08", "03", "01"], - ["Harari", "Harar Hundanee", "Amir Nur Woreda", "09", "01", "01"], - ["Harari", "Harar Hundanee", "Abadir Woreda", "09", "01", "02"], - ["Addis Ababa", "Bole Sub-City", "Bole Woreda 01", "10", "01", "01"], - ["Addis Ababa", "Kirkos Sub-City", "Kirkos Woreda 01", "10", "02", "01"], - ["Addis Ababa", "Nifas Silk Lafto", "NSL Woreda 13", "10", "03", "13"], - ["Addis Ababa", "Yeka Sub-City", "Yeka Woreda 01", "10", "04", "01"], - ["Addis Ababa", "Arada Sub-City", "Arada Woreda 01", "10", "05", "01"], - ["Dire Dawa", "Dire Dawa Urban", "Melka Jebdu", "11", "01", "01"], - ["Dire Dawa", "Dire Dawa Rural", "Gurgura", "11", "02", "01"], - ["Sidama", "Hawassa City Admin", "Hayek Chereka", "12", "01", "01"], - ["Sidama", "Sidama Zuria", "Yirgalem Town", "12", "02", "01"], - ["Sidama", "Sidama Zuria", "Aleta Wendo", "12", "02", "02"], - ["Southwest Ethiopia", "Keffa", "Bonga Town", "13", "01", "01"], - ["Southwest Ethiopia", "Sheka", "Mappi Zuria", "13", "02", "01"], - ["Southwest Ethiopia", "Bench Sheko", "Mizan Aman", "13", "03", "01"], - ["Central Ethiopia", "Gurage", "Wolkite", "14", "01", "01"], - ["Central Ethiopia", "Hadiya", "Hosaina", "14", "02", "01"], - ["Central Ethiopia", "Silte", "Worabe", "14", "03", "01"], - ["Gedeo State", "Gedeo Zone", "Dilla Zuria", "15", "01", "01"], - ["Gedeo State", "Gedeo Zone", "Yirgacheffe", "15", "01", "02"], -]; - -/** First occurrence wins on a name collision — see the class comment. */ -const buildMap = (pick: (row: (typeof ROWS)[number]) => [string, string]): Record => { - const map: Record = {}; - for (const row of ROWS) { - const [name, code] = pick(row); - if (!(name in map)) map[name] = code; - } - return map; -}; - -export const ETHIOPIA_REGION_CODES: Record = buildMap((r) => [r[0], r[3]]); -/** Zone name → code. Fed into `buyerCityCodes` — EIMS's "City" is really the buyer's zone. */ -export const ETHIOPIA_ZONE_CODES: Record = buildMap((r) => [r[1], r[4]]); -export const ETHIOPIA_WOREDA_CODES: Record = buildMap((r) => [r[2], r[5]]); - -/** - * Buyer records commonly store just the bare Addis Ababa sub-city name ("Bole", "Arada") as their - * woreda, not the source CSV's specific example-woreda name ("Bole Woreda 01") — confirmed live - * 2026-08-17 across three different buyers before any of them actually got past this check. Since - * the CSV lists exactly one representative woreda per Addis sub-city, alias the bare name to that - * same code rather than wait on a fuller table. - */ -const ADDIS_SUBCITY_ALIASES: Array<[bareName: string, csvZoneName: string]> = [ - ["Bole", "Bole Sub-City"], - ["Kirkos", "Kirkos Sub-City"], - ["Nifas Silk Lafto", "Nifas Silk Lafto"], - // Matches EIMS_BUYER_WEREDA_CODES' own existing spelling in .env — same zone, different hyphenation. - ["Nefas Silk-Lafto", "Nifas Silk Lafto"], - ["Yeka", "Yeka Sub-City"], - ["Arada", "Arada Sub-City"], -]; -for (const [bareName, csvZoneName] of ADDIS_SUBCITY_ALIASES) { - const row = ROWS.find((r) => r[1] === csvZoneName); - if (row && !(bareName in ETHIOPIA_WOREDA_CODES)) ETHIOPIA_WOREDA_CODES[bareName] = row[5]; -} diff --git a/apps/edr-freight-api/src/config/mor-location.resolver.spec.ts b/apps/edr-freight-api/src/config/mor-location.resolver.spec.ts new file mode 100644 index 000000000..0fc2607e2 --- /dev/null +++ b/apps/edr-freight-api/src/config/mor-location.resolver.spec.ts @@ -0,0 +1,275 @@ +import { MorLocationTuple } from "./mor-locations.data"; +import { + MorGeoMappingError, + normalizeName, + resolveMorGeo, + tryResolveMorGeo, +} from "./mor-location.resolver"; + +/** + * Rows copied verbatim out of the Ministry sheet (`EIMS_COUNTRY_REGION_VW`), chosen for the traps + * the real data contains rather than for tidiness: + * + * - BABILE and KERSA each exist in two different zones with different LOCALITY_NOs — the reason a + * global name lookup is unsafe and the hierarchy is mandatory. + * - ILLUBABOR has BURE twice under the same zone with different LOCALITY_NOs (691 and 890, the + * second with the Ministry's own trailing space) — a genuine ambiguity that must never be + * silently resolved to the first row. + * - "Wal-Mera" and "Akaki woreda" carry the sheet's mixed casing and punctuation. + */ +const FIXTURE: MorLocationTuple[] = [ + [70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 190, "JIJIGA"], + [70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 194, "BABILE"], + [70, "Ethiopia", 6, "SOMALI", 30, "SITI ZONE", 197, "DENBEL"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 495, "BABILE"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 482, "KERSA"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 503, "KERSA"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 691, "BURE"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 890, "BURE "], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 976, "Wal-Mera"], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 909, "Akaki woreda"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1100, "WOREDA 1"], + [253, "Djibouti", 1, "DJIBOUTI", 1, "DJIBOUTI VILLE", 1, "BALBALA"], +]; + +const JIJIGA = { + country: "Ethiopia", + region: "SOMALI", + zone: "FAAFAN ZONE", + woreda: "JIJIGA", +}; + +describe("normalizeName", () => { + it("collapses whitespace, trims, and compares case-insensitively", () => { + expect(normalizeName(" FAAFAN ZONE ")).toBe("FAAFAN ZONE"); + expect(normalizeName("faafan zone")).toBe("FAAFAN ZONE"); + expect(normalizeName(" FAAFAN ZONE ")).toBe(normalizeName("faafan zone")); + }); + + it("normalizes harmless punctuation and hyphen/space differences", () => { + expect(normalizeName("Wal-Mera")).toBe("WAL MERA"); + expect(normalizeName("Wal Mera")).toBe("WAL MERA"); + expect(normalizeName("ZONE 1 (AYSSAITA)")).toBe("ZONE 1 AYSSAITA"); + expect(normalizeName("Ber'ano")).toBe("BERANO"); + expect(normalizeName("KEAHORE/HADAT/")).toBe("KEAHORE HADAT"); + }); + + it("keeps digits, which several MoR locality names depend on", () => { + expect(normalizeName(" woreda 10 ")).toBe("WOREDA 10"); + expect(normalizeName("WOREDA 1")).not.toBe(normalizeName("WOREDA 10")); + }); +}); + +describe("resolveMorGeo", () => { + it("resolves the exact MoR spelling to the Ministry's own codes", () => { + expect(resolveMorGeo(JIJIGA, FIXTURE)).toEqual({ + Country: "70", + Region: "6", + City: "31", + Wereda: "190", + }); + }); + + it("resolves the EDR/e-Trade spellings through the alias layer", () => { + expect( + resolveMorGeo( + { + country: "Ethiopia", + region: "Somali", + zone: "Fafen", + woreda: "Jigjiga", + }, + FIXTURE, + ), + ).toEqual({ Country: "70", Region: "6", City: "31", Wereda: "190" }); + }); + + it("is case-insensitive", () => { + expect( + resolveMorGeo( + { + country: "ethiopia", + region: "somali", + zone: "faafan zone", + woreda: "jijiga", + }, + FIXTURE, + ), + ).toEqual({ Country: "70", Region: "6", City: "31", Wereda: "190" }); + }); + + it("ignores leading, trailing and repeated whitespace on every level", () => { + expect( + resolveMorGeo( + { + country: " Ethiopia ", + region: " SOMALI ", + zone: " FAAFAN ZONE ", + woreda: "\tJIJIGA ", + }, + FIXTURE, + ), + ).toEqual({ Country: "70", Region: "6", City: "31", Wereda: "190" }); + }); + + it("treats a hyphen as a space, in either direction", () => { + const expected = { Country: "70", Region: "2", City: "86", Wereda: "976" }; + const base = { + country: "Ethiopia", + region: "Oromia", + zone: "Finfine Vic Spec", + }; + expect(resolveMorGeo({ ...base, woreda: "Wal-Mera" }, FIXTURE)).toEqual(expected); + expect(resolveMorGeo({ ...base, woreda: "wal mera" }, FIXTURE)).toEqual(expected); + }); + + it("matches a zone whose MoR label carries the ' ZONE' suffix EDR does not store", () => { + expect(resolveMorGeo({ ...JIJIGA, zone: "Faafan" }, FIXTURE).City).toBe("31"); + expect( + resolveMorGeo( + { + country: "Ethiopia", + region: "Somali", + zone: "Siti", + woreda: "Denbel", + }, + FIXTURE, + ), + ).toEqual({ Country: "70", Region: "6", City: "30", Wereda: "197" }); + }); + + describe("a locality name that exists in more than one zone", () => { + it("picks BABILE by its full hierarchy, never by name alone", () => { + expect(resolveMorGeo({ ...JIJIGA, woreda: "BABILE" }, FIXTURE).Wereda).toBe("194"); + expect( + resolveMorGeo( + { + country: "Ethiopia", + region: "OROMIA", + zone: "MISRAK HARARGE", + woreda: "BABILE", + }, + FIXTURE, + ).Wereda, + ).toBe("495"); + }); + + it("picks KERSA by its full hierarchy", () => { + const oromia = { country: "Ethiopia", region: "OROMIA" }; + expect( + resolveMorGeo({ ...oromia, zone: "MISRAK HARARGE", woreda: "KERSA" }, FIXTURE).Wereda, + ).toBe("482"); + expect( + resolveMorGeo({ ...oromia, zone: "JIMMA ZONE", woreda: "KERSA" }, FIXTURE).Wereda, + ).toBe("503"); + }); + + it("does not let a locality leak across regions", () => { + // DENBEL exists under SOMALI/SITI ZONE only — asking for it under OROMIA must fail, not + // fall back to the nationwide match the old flat maps would have found. + expect(() => + resolveMorGeo( + { + country: "Ethiopia", + region: "OROMIA", + zone: "MISRAK HARARGE", + woreda: "DENBEL", + }, + FIXTURE, + ), + ).toThrow(/no MoR LOCALITY_DESC match/); + }); + }); + + describe("failures happen locally, before anything is filed", () => { + const cases: Array<[string, Record, RegExp]> = [ + ["unknown country", { ...JIJIGA, country: "Wakanda" }, /no MoR COUNTRY_NAME match/], + ["unknown region", { ...JIJIGA, region: "Atlantis" }, /no MoR PARISH_NAME match/], + ["unknown zone", { ...JIJIGA, zone: "Nowhere Zone" }, /no MoR CITY_NAME match/], + ["unknown woreda", { ...JIJIGA, woreda: "Example" }, /no MoR LOCALITY_DESC match/], + ]; + + it.each(cases)("%s fails with an actionable validation error", (_label, input, pattern) => { + expect(() => resolveMorGeo(input, FIXTURE)).toThrow(MorGeoMappingError); + expect(() => resolveMorGeo(input, FIXTURE)).toThrow(pattern); + }); + + it("names the offending address in the message so the company record can be corrected", () => { + expect(() => resolveMorGeo({ ...JIJIGA, woreda: "Example" }, FIXTURE)).toThrow( + /country="Ethiopia", region="SOMALI", zone="FAAFAN ZONE", woreda="Example"/, + ); + }); + + it("refuses an ambiguous locality instead of taking the first row", () => { + const input = { + country: "Ethiopia", + region: "OROMIA", + zone: "ILLUBABOR", + woreda: "BURE", + }; + expect(() => resolveMorGeo(input, FIXTURE)).toThrow(MorGeoMappingError); + expect(() => resolveMorGeo(input, FIXTURE)).toThrow(/ambiguous/); + // Both colliding codes are named, and neither is silently selected. + expect(() => resolveMorGeo(input, FIXTURE)).toThrow(/691, 890/); + expect(tryResolveMorGeo(input, FIXTURE)).toBeNull(); + }); + + it("fails loudly when the MoR master has not been generated yet", () => { + expect(() => resolveMorGeo(JIJIGA, [])).toThrow(/MoR location master is empty/); + }); + }); + + it("reproduces MoR's numeric values unchanged, as strings", () => { + const codes = resolveMorGeo(JIJIGA, FIXTURE); + expect(codes).toEqual({ + Country: "70", + Region: "6", + City: "31", + Wereda: "190", + }); + for (const value of Object.values(codes)) { + expect(typeof value).toBe("string"); + expect(value).toMatch(/^[0-9]+$/); + } + // The source row is the only origin of every code — no renumbering, no derivation. + const [countryNo, , parishNo, , cityNo, , localityNo] = FIXTURE[0]; + expect(codes).toEqual({ + Country: String(countryNo), + Region: String(parishNo), + City: String(cityNo), + Wereda: String(localityNo), + }); + }); + + it("never emits an Open Admin Data ETxx identifier", () => { + for (const value of Object.values(resolveMorGeo(JIJIGA, FIXTURE))) { + expect(value).not.toMatch(/^ET/i); + } + }); + + it("treats a blank country as domestic, matching the column default", () => { + expect(resolveMorGeo({ ...JIJIGA, country: "" }, FIXTURE).Country).toBe("70"); + expect(resolveMorGeo({ ...JIJIGA, country: null }, FIXTURE).Country).toBe("70"); + }); + + it("resolves a named foreign country rather than defaulting it to Ethiopia", () => { + expect( + resolveMorGeo( + { + country: "Djibouti", + region: "DJIBOUTI", + zone: "DJIBOUTI VILLE", + woreda: "BALBALA", + }, + FIXTURE, + ), + ).toEqual({ Country: "253", Region: "1", City: "1", Wereda: "1" }); + }); + + it("accepts a company record that already holds a MoR code, but only a real one", () => { + expect(resolveMorGeo({ ...JIJIGA, region: "6" }, FIXTURE).Region).toBe("6"); + expect(() => resolveMorGeo({ ...JIJIGA, region: "999" }, FIXTURE)).toThrow( + /no MoR PARISH_NAME match/, + ); + }); +}); diff --git a/apps/edr-freight-api/src/config/mor-location.resolver.ts b/apps/edr-freight-api/src/config/mor-location.resolver.ts new file mode 100644 index 000000000..72459db9b --- /dev/null +++ b/apps/edr-freight-api/src/config/mor-location.resolver.ts @@ -0,0 +1,255 @@ +import { BadRequestException } from "@nestjs/common"; + +import { MOR_LOCATIONS, MorLocationTuple } from "./mor-locations.data"; + +/** + * Resolves an EDR company address to the Ministry of Revenues' own EIMS location codes, using the + * MoR location master (`EIMS_COUNTRY_REGION_VW`) shipped in `mor-locations.data.ts`. + * + * MoR's field names do not line up with either EDR's or generic Ethiopian administrative datasets, + * so the mapping is fixed by the Ministry sheet, not by interpretation: + * + * Company.country -> COUNTRY_NAME -> COUNTRY_NO -> BuyerDetails.Country + * Company.region -> PARISH_NAME -> PARISH_NO -> BuyerDetails.Region + * Company.zone -> CITY_NAME -> CITY_NO -> BuyerDetails.City + * Company.woreda -> LOCALITY_DESC -> LOCALITY_NO -> BuyerDetails.Wereda + * + * This replaces the previous `EIMS_BUYER_*_CODES` environment maps and the `ethiopia-geo-codes.ts` + * table they layered over. Both invented their codes (sequential "01".."15" per region, from a + * generic administrative CSV) and both looked names up **globally**, which cannot be correct: + * KERSA, GORO, BABILE and BURE each occur in several different zones with different LOCALITY_NOs. + * A global name lookup silently picked the first, i.e. filed a real invoice against whichever tax + * jurisdiction happened to sort first. Resolution here is strictly hierarchical — each level is + * searched only within the rows its parent already selected. + * + * Open Admin Data identifiers (`ET14`, `ET0407`, …) are unrelated to this code system and must + * never appear in an EIMS payload; nothing in this module can emit one, since every returned value + * comes from a numeric column of the Ministry sheet. + */ + +export interface MorGeoCodes { + /** COUNTRY_NO as a string — `BuyerDetails.Country`. */ + Country: string; + /** PARISH_NO as a string — `BuyerDetails.Region`. */ + Region: string; + /** CITY_NO as a string — `BuyerDetails.City`. MoR calls the zone level "City". */ + City: string; + /** LOCALITY_NO as a string — `BuyerDetails.Wereda`. */ + Wereda: string; +} + +export interface MorAddressInput { + country?: string | null; + region?: string | null; + zone?: string | null; + woreda?: string | null; +} + +type Level = "country" | "region" | "zone" | "woreda"; + +/** Which tuple slots hold the name and the code at each level. */ +const SLOTS: Record = { + country: { name: 1, no: 0, column: "COUNTRY_NAME" }, + region: { name: 3, no: 2, column: "PARISH_NAME" }, + zone: { name: 5, no: 4, column: "CITY_NAME" }, + woreda: { name: 7, no: 6, column: "LOCALITY_DESC" }, +}; + +/** + * One normalized form for both sides of every comparison. Deliberately conservative: it removes + * differences that cannot change which jurisdiction is meant (case, stray and repeated whitespace, + * hyphen/slash/parenthesis/apostrophe punctuation, combining accents) and nothing else. There is + * no fuzzy or edit-distance matching anywhere in this module — a near-miss must fail loudly rather + * than file an invoice against a neighbouring woreda. + * + * " FAAFAN ZONE " -> "FAAFAN ZONE" + * "Wal-Mera" -> "WAL MERA" + * "Ber'ano" -> "BERANO" + * "ZONE 1 (AYSSAITA)"-> "ZONE 1 AYSSAITA" + */ +const normalizeCache = new Map(); +export function normalizeName(value: string | null | undefined): string { + const raw = value ?? ""; + const hit = normalizeCache.get(raw); + if (hit !== undefined) return hit; + const normalized = raw + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toUpperCase() + .replace(/['\u2018\u2019`]/g, "") + .replace(/[^A-Z0-9]+/g, " ") + .trim(); + normalizeCache.set(raw, normalized); + return normalized; +} + +/** + * Reviewed spelling differences between what EDR/e-Trade store and what the Ministry sheet calls + * the same place. Every entry is scoped to the administrative level it applies to, and to its + * parent where the name is not unique nationwide — so an alias can never reach across into another + * region's jurisdiction. `from`/`to` are compared normalized, so casing and spacing here are + * cosmetic. + * + * Add an entry only after confirming the two names are the same place in the Ministry sheet. This + * is the only sanctioned place for spelling compatibility; `mor-locations.data.ts` stays verbatim. + */ +interface MorAlias { + level: Exclude; + /** Parent scope, normalized-compared. Omit a level to leave the alias unscoped at that level. */ + region?: string; + zone?: string; + from: string; + to: string; +} + +const ALIASES: MorAlias[] = [ + // e-Trade and the customer portal both spell the Somali zone "Fafen"; MoR spells it "FAAFAN + // ZONE". Confirmed same zone (CITY_NO 31) — this is the buyer that first exposed the whole + // fabricated-code problem. + { level: "zone", region: "SOMALI", from: "Fafen", to: "FAAFAN ZONE" }, + // MoR's own capital of that zone is "JIJIGA"; every other source spells it "Jigjiga". + { + level: "woreda", + region: "SOMALI", + zone: "FAAFAN ZONE", + from: "Jigjiga", + to: "JIJIGA", + }, +]; + +/** + * MoR suffixes many zone labels with " ZONE" ("JIMMA ZONE", "FAAFAN ZONE", "SITI ZONE") while EDR + * stores the bare name. Retrying the suffixed spelling is an exact match against a second candidate + * string, scoped to the already-resolved region — not fuzzy matching — and it removes a long tail + * of otherwise hand-maintained aliases. Applied to the zone level only: locality suffixes + * ("WOREDA", "TOWN ADMINISTRATION") are not mechanical and could select a different place. + */ +const zoneSuffixCandidates = (normalized: string): string[] => + normalized.endsWith(" ZONE") ? [] : [`${normalized} ZONE`]; + +export class MorGeoMappingError extends BadRequestException { + constructor(code: "EIMS_GEO_MAPPING_FAILED" | "EIMS_GEO_AMBIGUOUS", message: string) { + super({ code, message }); + } +} + +/** Renders the address being resolved for an error message. No customer-identifying data. */ +const describe = (input: MorAddressInput): string => + `country="${input.country ?? ""}", region="${input.region ?? ""}", ` + + `zone="${input.zone ?? ""}", woreda="${input.woreda ?? ""}"`; + +function matchLevel( + rows: MorLocationTuple[], + level: Level, + raw: string | null | undefined, + parents: { region?: string; zone?: string }, + input: MorAddressInput, +): { no: number; rows: MorLocationTuple[] } { + const { name: nameSlot, no: noSlot, column } = SLOTS[level]; + const wanted = normalizeName(raw); + + const candidates: string[] = []; + if (wanted) { + candidates.push(wanted); + for (const alias of ALIASES) { + if (alias.level !== level) continue; + if (alias.region && normalizeName(alias.region) !== parents.region) continue; + if (alias.zone && normalizeName(alias.zone) !== parents.zone) continue; + if (normalizeName(alias.from) === wanted) candidates.push(normalizeName(alias.to)); + } + if (level === "zone") candidates.push(...zoneSuffixCandidates(wanted)); + } + + let matched: MorLocationTuple[] = []; + for (const candidate of candidates) { + matched = rows.filter((row) => normalizeName(row[nameSlot] as string) === candidate); + if (matched.length > 0) break; + } + + // A company record that already holds the MoR code itself resolves too — but only when that code + // genuinely exists at this level under this parent. An unvalidated numeric pass-through is how a + // wrong code reaches MoR without anything noticing. + if (matched.length === 0 && /^[0-9]{1,6}$/.test((raw ?? "").trim())) { + const asCode = Number((raw ?? "").trim()); + matched = rows.filter((row) => row[noSlot] === asCode); + } + + if (matched.length === 0) { + throw new MorGeoMappingError( + "EIMS_GEO_MAPPING_FAILED", + `EIMS geographic mapping failed: no MoR ${column} match for ${describe(input)}.`, + ); + } + + const distinct = [...new Set(matched.map((row) => row[noSlot] as number))]; + if (distinct.length > 1) { + throw new MorGeoMappingError( + "EIMS_GEO_AMBIGUOUS", + `EIMS geographic mapping is ambiguous: MoR ${column} "${(raw ?? "").trim()}" matches ` + + `${distinct.length} different codes (${distinct.sort((a, b) => a - b).join(", ")}) for ` + + `${describe(input)}. Correct the company address or the MoR reference data; an ambiguous ` + + "location is never filed.", + ); + } + + return { no: distinct[0], rows: matched }; +} + +/** + * Resolves the full hierarchy, or throws a `BadRequestException` naming the level that failed. + * + * Never guesses and never returns a partial result: an unknown or ambiguous location must stop the + * filing here, locally, before any MoR request and before an EIMS counter is consumed. + */ +export function resolveMorGeo( + input: MorAddressInput, + rows: MorLocationTuple[] = MOR_LOCATIONS, +): MorGeoCodes { + if (rows.length === 0) { + throw new MorGeoMappingError( + "EIMS_GEO_MAPPING_FAILED", + "EIMS geographic mapping failed: the MoR location master is empty. Generate it with " + + "`pnpm --filter @edr/freight-api eims:import-locations `.", + ); + } + + // `companies.country` defaults to 'Ethiopia' and is often left blank on older rows; blank means + // domestic here, exactly as the column default says. A *named* foreign country is resolved like + // any other and fails if MoR does not list it — it is never quietly filed as Ethiopia. + const country = (input.country ?? "").trim() || "Ethiopia"; + + const inCountry = matchLevel(rows, "country", country, {}, input); + const inRegion = matchLevel(inCountry.rows, "region", input.region, {}, input); + const regionScope = normalizeName(inRegion.rows[0][SLOTS.region.name] as string); + const inZone = matchLevel(inRegion.rows, "zone", input.zone, { region: regionScope }, input); + const zoneScope = normalizeName(inZone.rows[0][SLOTS.zone.name] as string); + const inWoreda = matchLevel( + inZone.rows, + "woreda", + input.woreda, + { + region: regionScope, + zone: zoneScope, + }, + input, + ); + + return { + Country: String(inCountry.no), + Region: String(inRegion.no), + City: String(inZone.no), + Wereda: String(inWoreda.no), + }; +} + +/** Non-throwing variant for callers that already have a working fallback (the seller identity). */ +export function tryResolveMorGeo( + input: MorAddressInput, + rows: MorLocationTuple[] = MOR_LOCATIONS, +): MorGeoCodes | null { + try { + return resolveMorGeo(input, rows); + } catch { + return null; + } +} diff --git a/apps/edr-freight-api/src/config/mor-locations.data.ts b/apps/edr-freight-api/src/config/mor-locations.data.ts new file mode 100644 index 000000000..b3b49c843 --- /dev/null +++ b/apps/edr-freight-api/src/config/mor-locations.data.ts @@ -0,0 +1,18 @@ +/** + * GENERATED FILE — do not hand-edit. + * + * MoR EIMS location master (`EIMS_COUNTRY_REGION_VW`), the Ministry's own geographic reference + * data. Regenerate from a supplied workbook with: + * + * pnpm --filter @edr/freight-api eims:import-locations + * + * Values are reproduced verbatim from the Ministry sheet — original spelling, original casing, + * original numbering, duplicates included. Nothing here is cleaned up or renumbered: this file is + * the traceable copy of the source. Spelling compatibility between EDR/e-Trade names and MoR names + * belongs in `mor-location.resolver.ts`'s normalization and alias layer, never here. + */ + +/** `[COUNTRY_NO, COUNTRY_NAME, PARISH_NO, PARISH_NAME, CITY_NO, CITY_NAME, LOCALITY_NO, LOCALITY_DESC]` */ +export type MorLocationTuple = [number, string, number, string, number, string, number, string]; + +export const MOR_LOCATIONS: MorLocationTuple[] = []; diff --git a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts index 2f1991df2..8990e1b43 100644 --- a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts @@ -67,6 +67,7 @@ const TRIGGER_ROUTE_LABELS: Partial> = { DEMURRAGE: 'Demurrage / wagon detention', PIL_EXTRA_FEE: 'PIL shipping line extra fee', CUSTOMS_CLEARANCE: 'Customs clearance service', + ETHIOPIAN_CUSTOMS_CLEARANCE: 'Ethiopian customs clearance service', FUEL: 'Fuel surcharge', }; diff --git a/apps/edr-freight-api/src/migrations/3610000000000-MultipleMiscClearanceCharges.ts b/apps/edr-freight-api/src/migrations/3610000000000-MultipleMiscClearanceCharges.ts new file mode 100644 index 000000000..5cdf6f489 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3610000000000-MultipleMiscClearanceCharges.ts @@ -0,0 +1,38 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Clearance charges are no longer one-of-each in a fixed order: GL Ethiopia + * may raise several MISCELLANEOUS charges, and either level may be created + * first. Port charges stay unique per booking (one port bill per shipment), + * enforced by a partial index instead of the old blanket (booking_id, type) + * uniqueness that also capped miscellaneous at one. + */ +export class MultipleMiscClearanceCharges3610000000000 + implements MigrationInterface +{ + name = 'MultipleMiscClearanceCharges3610000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS "freight"."uq_booking_clearance_charge_booking_type" + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "uq_booking_clearance_charge_port" + ON "freight"."booking_clearance_charge" ("booking_id") + WHERE "type" = 'PORT_CHARGES' AND "deleted_at" IS NULL + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "idx_booking_clearance_charge_booking" + ON "freight"."booking_clearance_charge" ("booking_id") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // No-op on the uniqueness: restoring the blanket (booking_id, type) index + // would fail on any booking that has since raised a second miscellaneous + // charge, which is exactly what this migration set out to allow. + await queryRunner.query(` + DROP INDEX IF EXISTS "freight"."uq_booking_clearance_charge_port" + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3620000000000-AdditionalCharge.ts b/apps/edr-freight-api/src/migrations/3620000000000-AdditionalCharge.ts new file mode 100644 index 000000000..2525c1b3d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3620000000000-AdditionalCharge.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** Ad-hoc customer charges finance raises against a booking — Additional Payments tab. */ +export class AdditionalCharge3620000000000 implements MigrationInterface { + name = 'AdditionalCharge3620000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "freight"."additional_charge" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz, + "booking_id" uuid NOT NULL, + "reason" text NOT NULL, + "status" character varying(20) NOT NULL DEFAULT 'DRAFT', + "amount" numeric(14,2) NOT NULL, + "currency" character varying(8) NOT NULL, + "file_record_id" uuid, + "invoice_id" uuid, + "payment_reference" character varying(64), + "created_by_staff_id" uuid, + "sent_by_staff_id" uuid, + "sent_at" timestamptz, + "paid_at" timestamptz, + "cancelled_by_staff_id" uuid, + "cancelled_at" timestamptz, + "cancel_reason" text, + CONSTRAINT "pk_additional_charge" PRIMARY KEY ("id"), + CONSTRAINT "fk_additional_charge_booking" FOREIGN KEY ("booking_id") + REFERENCES "freight"."bookings"("id") ON DELETE CASCADE + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "idx_additional_charge_booking" + ON "freight"."additional_charge" ("booking_id") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "freight"."additional_charge"`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3620000000000-SchedulePlannedWagonYards.ts b/apps/edr-freight-api/src/migrations/3620000000000-SchedulePlannedWagonYards.ts new file mode 100644 index 000000000..9313c22a5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3620000000000-SchedulePlannedWagonYards.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-schedule wagon yard plan — where THIS departure expects each consist + * wagon to board, independent of where the wagon physically stands today. + * + * `wagons.current_yard_id` is one physical fact shared by every schedule of a + * built train, so a train standing in Mojo could not be sold from Dire for a + * departure next week. The plan is a sparse jsonb map `{ wagonId: yardId }` + * on the schedule: a wagon missing from the map boards from its physical yard. + * Booking capacity, fleet availability and wagon pinning all read the plan; + * dispatch refuses to leave until the plan and the physical yards agree. + */ +export class SchedulePlannedWagonYards3620000000000 implements MigrationInterface { + name = 'SchedulePlannedWagonYards3620000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS planned_wagon_yards jsonb + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS planned_wagon_yards + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3630000000000-ClearanceChargeCustomerDecision.ts b/apps/edr-freight-api/src/migrations/3630000000000-ClearanceChargeCustomerDecision.ts new file mode 100644 index 000000000..a9cb259cd --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3630000000000-ClearanceChargeCustomerDecision.ts @@ -0,0 +1,49 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * The customer now approves a clearance charge before it becomes an invoice: + * GL describes the price, SENDs it, the customer ACCEPTs (invoice issued, charge + * locked) or REJECTs with a note (GL revises and re-sends). Charges that were + * already sent as invoices under the old flow are carried over as ACCEPTED so + * their invoices stay payable. + */ +export class ClearanceChargeCustomerDecision3630000000000 + implements MigrationInterface +{ + name = 'ClearanceChargeCustomerDecision3630000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "freight"."booking_clearance_charge" + ADD COLUMN IF NOT EXISTS "description" text, + ADD COLUMN IF NOT EXISTS "customer_note" text, + ADD COLUMN IF NOT EXISTS "customer_decided_at" timestamptz, + ADD COLUMN IF NOT EXISTS "customer_decided_by" uuid + `); + await queryRunner.query(` + UPDATE "freight"."booking_clearance_charge" + SET "status" = 'ACCEPTED' + WHERE "status" = 'SENT' AND "invoice_id" IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE "freight"."booking_clearance_charge" + SET "status" = 'SENT' + WHERE "status" = 'ACCEPTED' + `); + await queryRunner.query(` + UPDATE "freight"."booking_clearance_charge" + SET "status" = 'BILLED' + WHERE "status" = 'REJECTED' + `); + await queryRunner.query(` + ALTER TABLE "freight"."booking_clearance_charge" + DROP COLUMN IF EXISTS "description", + DROP COLUMN IF EXISTS "customer_note", + DROP COLUMN IF EXISTS "customer_decided_at", + DROP COLUMN IF EXISTS "customer_decided_by" + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3640000000000-EthiopianCustomsClearance.ts b/apps/edr-freight-api/src/migrations/3640000000000-EthiopianCustomsClearance.ts new file mode 100644 index 000000000..50ceda3ca --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3640000000000-EthiopianCustomsClearance.ts @@ -0,0 +1,69 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Ethiopian-side-only customs clearance: + * + * - service_types.includes_ethiopian_customs_only marks a customs service that + * EDR clears on the Ethiopian side only. Same clearance flow; only the fee + * differs — pricing looks up the ETHIOPIAN_CUSTOMS_CLEARANCE rate instead of + * CUSTOMS_CLEARANCE. + * - rates.trigger widens to 30 chars to fit the new trigger value. + * - CK_rates_yard_scope gains ETHIOPIAN_CUSTOMS_CLEARANCE in its yard-carrying + * branch: it is priced per origin → destination leg like customs clearance. + */ +export class EthiopianCustomsClearance3640000000000 implements MigrationInterface { + name = 'EthiopianCustomsClearance3640000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.service_types + ADD COLUMN IF NOT EXISTS includes_ethiopian_customs_only boolean NOT NULL DEFAULT false + `); + + await queryRunner.query( + `ALTER TABLE freight.rates ALTER COLUMN trigger TYPE varchar(30)`, + ); + + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR + CASE + WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')) + OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL') + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR + CASE + WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')) + OR trigger IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL') + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ) + `); + // Rows on the new trigger would not fit varchar(20) — drop them first. + await queryRunner.query( + `DELETE FROM freight.rates WHERE trigger = 'ETHIOPIAN_CUSTOMS_CLEARANCE'`, + ); + await queryRunner.query( + `ALTER TABLE freight.rates ALTER COLUMN trigger TYPE varchar(20)`, + ); + await queryRunner.query( + `ALTER TABLE freight.service_types DROP COLUMN IF EXISTS includes_ethiopian_customs_only`, + ); + } +} 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/audit/audit-endpoints.ts b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts index deda35e1c..019d99920 100644 --- a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts +++ b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts @@ -47,6 +47,7 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/bookings/:id/clearance/proceed": ["Customer requests operation with a schedule day", "POST", "Booking"], "POST /api/bookings/:id/clearance/release-order": ["Upload Booking Release Order", "POST", "Booking"], "POST /api/bookings/:id/clearance/review": ["GL reviews a clearance document (Approve | Query)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/doc-requests": ["GL asks the customer for additional clearance documents", "POST", "Booking"], "POST /api/bookings/:id/clearance/charges/port-document": ["GL Djibouti uploads the port-charges document", "POST", "Booking"], "PATCH /api/bookings/:id/clearance/charges/:chargeId/bill": ["GL Ethiopia sets or revises a clearance charge's amount + currency", "PATCH", "Booking"], "POST /api/bookings/:id/clearance/charges/:chargeId/send": ["GL Ethiopia issues the clearance charge invoice to the customer", "POST", "Booking"], 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 10a20ea03..175e3e00c 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -13,6 +13,8 @@ import { logCtx } from "@edr/api-common"; 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"; @@ -737,7 +739,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"], @@ -2057,6 +2066,14 @@ export class BillingService { .getRepository(Booking) .update({ id: invoice.sourceId }, { pnrCode: billReference }); } + // Same reference, for an ad-hoc additional charge — its own column, since + // an AdditionalCharge doesn't own a Booking-scoped `pnrCode` and a booking + // can carry many of these at once. + if (billReference && invoice.source === Freight.InvoiceSource.AdditionalCharge) { + await this.dataSource + .getRepository(AdditionalCharge) + .update({ id: invoice.sourceId }, { paymentReference: billReference }); + } // Settlement is driven by the payment API (webhook/outbox → payment.succeeded); // billing must not simulate it. Kept for local demos only. diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts index 75d39a500..a3103ca1c 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts @@ -60,11 +60,7 @@ const context = (over: Partial = {}): EimsMapperContext => ({ unitDefault: "PCS", incomeWithholdValue: 0, transactionWithholdValue: 0, - buyerCountryCode: "231", // test-only, not a confirmed real MoR code - buyerCountryCodes: {}, - buyerRegionCodes: { "Addis Ababa": "13" }, - buyerWeredaCodes: {}, - buyerCityCodes: {}, + buyerGeo: { Country: "70", Region: "6", City: "31", Wereda: "190" }, ...over, }); @@ -94,10 +90,9 @@ describe("toEimsInvoice", () => { const doc = toEimsInvoice(invoice(), seller, context()); expect(doc.BuyerDetails).toEqual({ - City: null, - // company.country is "Ethiopia" (the domestic default) — resolves to context's flat - // buyerCountryCode fallback, not null, per resolveCountryCode. - Country: "231", + // Resolved by the registration service before the counter was reserved; the mapper copies. + City: "31", + Country: "70", Email: "buyer@abc.et", HouseNumber: "NEW", IdNumber: null, @@ -105,11 +100,11 @@ describe("toEimsInvoice", () => { Tin: "0999930000", LegalName: "ABC Trading PLC", Phone: "0912345678", - Region: "13", + Region: "6", Zone: "SHA", Kebele: "03", VatNumber: "123475885858", - Wereda: "574", + Wereda: "190", }); }); @@ -284,108 +279,39 @@ describe("toEimsInvoice", () => { }); describe("toEimsInvoice — MoR field constraints", () => { - it("passes a buyer region through when it is already a MoR code", () => { + /** + * Geography is no longer resolved here. `resolveMorGeo` runs in the registration service, ahead + * of the counter reservation, and hands the mapper finished MoR codes — so what these cover is + * that the resolved values reach the right `BuyerDetails` fields untouched. The lookup rules + * themselves (hierarchy, aliases, ambiguity) are covered in `mor-location.resolver.spec.ts`. + */ + it("puts the resolved MoR codes on BuyerDetails, unmodified and as strings", () => { const doc = toEimsInvoice(invoice(), seller, context()); - expect(doc.BuyerDetails.Region).toBe("13"); + + expect(doc.BuyerDetails.Country).toBe("70"); + expect(doc.BuyerDetails.Region).toBe("6"); + expect(doc.BuyerDetails.City).toBe("31"); + expect(doc.BuyerDetails.Wereda).toBe("190"); + for (const field of ["Country", "Region", "City", "Wereda"] as const) { + expect(typeof doc.BuyerDetails[field]).toBe("string"); + } }); - it("maps a region name to its code, ignoring case and spacing", () => { - const doc = toEimsInvoice( - invoice({ company: { ...invoice().company!, region: " addis ababa " } }), - seller, - context({ buyerRegionCodes: { "Addis Ababa": "13" } }), - ); - expect(doc.BuyerDetails.Region).toBe("13"); - }); - - it("refuses to file a buyer whose region has no mapping", () => { - expect(() => - toEimsInvoice( - invoice({ company: { ...invoice().company!, region: "Somewhere Else" } }), - seller, - context(), - ), - ).toThrow(/not a MoR Region code and has no mapping/); - }); - - it("refuses a buyer with no region at all rather than guessing one", () => { - expect(() => - toEimsInvoice( - invoice({ company: { ...invoice().company!, region: null } }), - seller, - context(), - ), - ).toThrow(/buyer Region \(unset\)/); - }); - - it("passes a buyer wereda through when it is already a MoR code", () => { + it("never emits an Open Admin Data ETxx identifier as a location", () => { const doc = toEimsInvoice(invoice(), seller, context()); - expect(doc.BuyerDetails.Wereda).toBe("574"); + for (const field of ["Country", "Region", "City", "Wereda"] as const) { + expect(doc.BuyerDetails[field]).toMatch(/^[0-9]+$/); + } }); - it("maps a wereda name to its code", () => { + it("keeps BuyerDetails.Zone as the buyer's own zone name — MoR takes that one as prose", () => { const doc = toEimsInvoice( - invoice({ company: { ...invoice().company!, woreda: "Yeka" } }), + invoice({ company: { ...invoice().company!, zone: "Fafen" } }), seller, - context({ buyerWeredaCodes: { Yeka: "99" } }), + context(), ); - expect(doc.BuyerDetails.Wereda).toBe("99"); - }); - - it("refuses to file a buyer whose wereda has no mapping", () => { - expect(() => - toEimsInvoice( - invoice({ company: { ...invoice().company!, woreda: "Yeka" } }), - seller, - context({ buyerWeredaCodes: {} }), - ), - ).toThrow(/buyer Wereda "Yeka".*EIMS_BUYER_WEREDA_CODES/); - }); - - it("derives City from the buyer's zone via the city code map", () => { - const doc = toEimsInvoice( - invoice({ company: { ...invoice().company!, zone: "Kirkos" } }), - seller, - context({ buyerCityCodes: { Kirkos: "101" } }), - ); - expect(doc.BuyerDetails.City).toBe("101"); - }); - - it("leaves City null (not a throw) when the buyer's zone has no city mapping — City is optional", () => { - const doc = toEimsInvoice( - invoice({ company: { ...invoice().company!, zone: "Somewhere Else" } }), - seller, - context({ buyerCityCodes: {} }), - ); - expect(doc.BuyerDetails.City).toBeNull(); - }); - - it("maps a buyer country name to its code via the country code map", () => { - const doc = toEimsInvoice( - invoice({ company: { ...invoice().company!, country: "Djibouti" } }), - seller, - context({ buyerCountryCodes: { Djibouti: "071" } }), - ); - expect(doc.BuyerDetails.Country).toBe("071"); - }); - - it("falls back to the flat domestic country code only for Ethiopia, not any unmapped country", () => { - const doc = toEimsInvoice( - invoice({ company: { ...invoice().company!, country: "Ethiopia" } }), - seller, - context({ buyerCountryCode: "231", buyerCountryCodes: {} }), - ); - expect(doc.BuyerDetails.Country).toBe("231"); - }); - - it("refuses a genuinely foreign buyer country with no mapping — never silently files it as Ethiopia", () => { - expect(() => - toEimsInvoice( - invoice({ company: { ...invoice().company!, country: "Kenya" } }), - seller, - context({ buyerCountryCode: "231", buyerCountryCodes: {} }), - ), - ).toThrow(/buyer Country "Kenya".*EIMS_BUYER_COUNTRY_CODES/); + expect(doc.BuyerDetails.Zone).toBe("Fafen"); + expect(doc.BuyerDetails.City).toBe("31"); }); it("emits NatureOfSupplies lowercase, whatever case it was configured in", () => { diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts index c72e61c7a..c1ae4c514 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts @@ -15,6 +15,7 @@ * authoritative: they are passed through or overridable rather than validated against a fixed set. */ +import { MorGeoCodes } from "../../config/mor-location.resolver"; import { round2 } from "./invoice-settlement.util"; /** Only proven-required constant: the 400 SCHEMA ERROR sample rejects a payload without it. */ @@ -235,35 +236,15 @@ export interface EimsMapperContext { */ relatedDocument?: string | null; /** - * Domestic fallback only, applied when `company.country` is empty or "Ethiopia" and not already - * in `buyerCountryCodes` — see that field. Never applied to a genuinely foreign buyer. - */ - buyerCountryCode?: string | null; - /** Country name → MoR code. Format unconfirmed, so looked up by name only, not digit-validated. */ - buyerCountryCodes: Record; - /** - * Region name → MoR numeric code, for buyers whose stored region is free text. + * The buyer's MoR location codes — `Country`/`Region`/`City`/`Wereda`, already resolved from the + * Ministry's location master by `resolveMorGeo`. * - * `companies.region` holds names ("Addis Ababa") while MoR validates `BuyerDetails.Region` - * against `^[0-9]{1,3}$`. A stored value that is already a code passes through; anything else - * must be in this map or the mapping **fails locally** — sending a guessed region code onto a - * tax document is worse than refusing to file. + * Resolved by the caller, not here, and deliberately so: geographic resolution can fail (unknown + * or ambiguous address) and that failure must happen **before** an EIMS counter is reserved, so a + * bad company address never burns a sequence number. See `mor-location.resolver.ts` for why the + * lookup has to be hierarchical, and `EimsInvoiceRegistrationService` for where it runs. */ - buyerRegionCodes: Record; - /** - * Wereda name → MoR code, same shape as `buyerRegionCodes`. `companies.woreda` holds names - * ("Yeka") or codes inconsistently; unlike Region, MoR has never named a Wereda regex in an - * error, so this is precautionary rather than confirmed — but the fix is identical either way: - * fail locally on an unmapped name rather than file a guess. - */ - buyerWeredaCodes: Record; - /** - * Buyer *zone* name → MoR City code. `Company` has no dedicated city column; Zone is the - * closest match in EDR's own data. Unlike Region/Wereda, City is optional — MoR has already - * accepted a live filing with it null — so an unmapped zone resolves to null, it does not fail - * the mapping. - */ - buyerCityCodes: Record; + buyerGeo: MorGeoCodes; buyerIdType?: string | null; buyerIdNumber?: string | null; /** Required when the invoice currency is not ETB. */ @@ -273,14 +254,6 @@ export interface EimsMapperContext { formatDate?: (issuedAt: Date) => string; } -/** - * MoR's own constraint on `Region`: one to three digits, confirmed by its 400 SCHEMA ERROR. Reused - * as the pass-through test for `Wereda` too — every Wereda value MoR has actually shown us (seller - * "12"/"13", the collection's "574") fits the same shape, though MoR has not named a Wereda regex - * the way it named Region's. - */ -const LOCATION_CODE = /^[0-9]{1,3}$/; - /** * The only two values MoR accepts for `NatureOfSupplies`, lowercase. * @@ -309,87 +282,6 @@ export const formatEimsDate = (issuedAt: Date): string => * an unissued invoice, unresolved line tax, a line/total mismatch, or a non-ETB invoice with no * exchange rate. */ -/** - * A buyer's location value (Region, Wereda or City) as a MoR code: passed through when already - * numeric, otherwise looked up by name (case- and space-insensitive). - * - * Region/Wereda are required: an unmapped value throws — sending a guessed code onto a tax - * document is worse than refusing to file. City is optional (`required: false`, City's own - * caller) — MoR has already accepted a live filing with it null, so an unmapped zone resolves to - * null instead of blocking the invoice. - */ -function resolveLocationCode( - field: "Region" | "Wereda" | "City", - value: string | null | undefined, - codes: Record, - envVar: string, - invoiceNumber: string, - opts: { required?: boolean } = {}, -): string | null { - const raw = (value ?? "").trim(); - if (LOCATION_CODE.test(raw)) return raw; - - const key = raw.toLowerCase().replace(/\s+/g, " "); - const mapped = Object.entries(codes).find( - ([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key, - )?.[1]; - if (mapped && LOCATION_CODE.test(mapped)) return mapped; - - if (opts.required === false) return null; - - throw new Error( - `EIMS mapping: invoice ${invoiceNumber} has buyer ${field} ${raw ? `"${raw}"` : "(unset)"}, ` + - `which is not a MoR ${field} code and has no mapping. Add it to ${envVar}.`, - ); -} - -/** - * A buyer's `Country` as a MoR code: looked up by name in `codes` first; when unmapped, applies - * `domesticFallback` only if the stored country is empty or "Ethiopia" (the DB column's default). - * A genuinely foreign, unmapped country throws rather than silently filing as Ethiopia — same - * "fail locally, don't guess" rule as `resolveLocationCode`, but never digit-validated: MoR's - * Country code format is unconfirmed, unlike Region/Wereda's proven `^[0-9]{1,3}$`. - */ -function resolveCountryCode( - country: string | null | undefined, - codes: Record, - domesticFallback: string | null, - invoiceNumber: string, -): string | null { - const raw = (country ?? "").trim(); - const key = raw.toLowerCase().replace(/\s+/g, " "); - const mapped = Object.entries(codes).find( - ([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key, - )?.[1]; - if (mapped) return mapped; - - if ((!raw || key === "ethiopia") && domesticFallback) return domesticFallback; - - throw new Error( - `EIMS mapping: invoice ${invoiceNumber} has buyer Country "${raw || "(unset)"}", which has no ` + - "MoR country code mapping. Add it to EIMS_BUYER_COUNTRY_CODES.", - ); -} - -/** - * Same name-or-code resolution as `resolveLocationCode`, for a caller with no invoice to attach an - * error to and that must never throw — currently only `EimsSellerCacheService`, resolving - * e-Trade's region/zone/woreda *names* for EDR's own seller identity. Pass-through numeric code, - * name lookup, `undefined` on no match — the caller falls back to static config either way. - */ -export function resolveOptionalCode( - value: string | null | undefined, - codes: Record, -): string | undefined { - const raw = (value ?? "").trim(); - if (LOCATION_CODE.test(raw)) return raw; - const key = raw.toLowerCase().replace(/\s+/g, " "); - const mapped = Object.entries(codes).find( - ([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key, - )?.[1]; - return mapped && LOCATION_CODE.test(mapped) ? mapped : undefined; -} - export function toEimsInvoice( invoice: EimsMapperInvoice, seller: EimsSellerDetails, @@ -511,16 +403,11 @@ export function toEimsInvoice( return { BuyerDetails: { - // No dedicated city column on Company — Zone is the closest match; optional (see - // resolveLocationCode's City comment). - City: resolveLocationCode( - "City", - company.zone, - context.buyerCityCodes, - "EIMS_BUYER_CITY_CODES", - invoice.invoiceNumber, - { required: false }, - ), + // Country/Region/City/Wereda are MoR location codes resolved from the Ministry's own + // location master *before* this mapper ran, and before an EIMS counter was reserved — see + // EimsMapperContext.buyerGeo. `Zone` alongside them is the buyer's free-text zone name, + // which MoR takes as prose, not a code. + City: context.buyerGeo.City, Email: company.email ?? null, HouseNumber: company.houseNo ?? null, IdNumber: context.buyerIdNumber ?? null, @@ -528,29 +415,12 @@ export function toEimsInvoice( Tin: company.tin, LegalName: company.name, Phone: company.phone ?? null, - Region: resolveLocationCode( - "Region", - company.region, - context.buyerRegionCodes, - "EIMS_BUYER_REGION_CODES", - invoice.invoiceNumber, - ), - Country: resolveCountryCode( - company.country, - context.buyerCountryCodes, - context.buyerCountryCode ?? null, - invoice.invoiceNumber, - ), + Region: context.buyerGeo.Region, + Country: context.buyerGeo.Country, Zone: company.zone ?? null, Kebele: company.kebele ?? null, VatNumber: company.vatNumber ?? null, - Wereda: resolveLocationCode( - "Wereda", - company.woreda, - context.buyerWeredaCodes, - "EIMS_BUYER_WEREDA_CODES", - invoice.invoiceNumber, - ), + Wereda: context.buyerGeo.Wereda, }, DocumentDetails: { DocumentNumber: context.documentNumber, diff --git a/apps/edr-freight-api/src/modules/bookings/ad-hoc-label.spec.ts b/apps/edr-freight-api/src/modules/bookings/ad-hoc-label.spec.ts new file mode 100644 index 000000000..dd67b7acc --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/ad-hoc-label.spec.ts @@ -0,0 +1,33 @@ +import { adHocLabel } from './clearance.util'; + +/** + * The customer's typed document name travels to the API inside the multipart + * field code (`custom__`) — the only channel a part has — and comes + * back out here for GL's review grid. Mirror of `adHocSlug` in the portal's + * useClearanceFlow. + */ +const adHocSlug = (name: string) => + name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60); + +const roundTrip = (typed: string) => adHocLabel(`custom_${adHocSlug(typed)}_17877000000000`); + +describe('adHocLabel', () => { + it('recovers the name the customer typed', () => { + expect(roundTrip('Special permit')).toBe('Special permit'); + expect(roundTrip('Fumigation Certificate')).toBe('Fumigation certificate'); + expect(roundTrip('bank slip #2')).toBe('Bank slip 2'); + }); + + it('returns null when there is no name to show, so callers use the filename', () => { + expect(roundTrip('')).toBeNull(); + // Legacy uploads keyed `custom__` carry no name — without the + // digits guard this would surface "1755780000000" as the document label. + expect(adHocLabel('custom_1755780000000_0')).toBeNull(); + expect(adHocLabel('commercial_invoice')).toBeNull(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/additional-charge.repository.ts b/apps/edr-freight-api/src/modules/bookings/additional-charge.repository.ts new file mode 100644 index 000000000..9a7e652b0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/additional-charge.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { AdditionalCharge } from './entities/additional-charge.entity'; + +@Injectable() +export class AdditionalChargeRepository extends BaseRepository { + constructor(@InjectRepository(AdditionalCharge) repository: Repository) { + super(repository); + } +} 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 new file mode 100644 index 000000000..012d5f8db --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts @@ -0,0 +1,314 @@ +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'; +import { Invoice } from '../billing/entities/invoice.entity'; +import { FilesService } from '../files/files.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { sendCompanyChannels } from '../notifications/notify-company.util'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { BookingsService } from './bookings.service'; +import { BookingsRepository } from './bookings.repository'; +import { AdditionalChargeRepository } from './additional-charge.repository'; +import { AdditionalCharge } from './entities/additional-charge.entity'; +import { CreateAdditionalChargeDto } from './dto/additional-charge.dto'; + +const FILE_RESOURCE = 'additional_charges'; + +/** + * Ad-hoc extra charges finance raises against a booking, independent of + * `BookingClearanceCharge` (which is capped at one PORT_CHARGES/MISCELLANEOUS + * row per booking). Any number per booking, free-text reason. DRAFT until + * sent; sending issues the payable invoice and notifies the customer + * (in-app + SMS + email). Settles via `additional_charge.invoice.paid`, + * same event-driven pattern as every other invoice source. + */ +@Injectable() +export class AdditionalChargeService { + private readonly logger = new Logger(AdditionalChargeService.name); + + constructor( + private readonly dataSource: DataSource, + 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, + private readonly inbox: NotificationInboxService, + ) {} + + private async findOwned(bookingId: string, chargeId: string): Promise { + const charge = await this.repository.findById(chargeId); + if (!charge || charge.bookingId !== bookingId) { + throw new NotFoundException('Additional charge not found'); + } + return charge; + } + + async list(bookingId: string): Promise { + const rows = await this.repository.findAll({ + where: { bookingId }, + order: { createdAt: 'DESC' }, + }); + return this.toDtoList(rows); + } + + async create( + bookingId: string, + dto: CreateAdditionalChargeDto, + staffId: string, + file?: Express.Multer.File, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + const shouldSend = dto.action === 'send'; + + const chargeId = await this.dataSource.transaction(async (manager) => { + const repo = manager.getRepository(AdditionalCharge); + let saved = await repo.save( + repo.create({ + bookingId, + 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, + }), + ); + + if (file) { + const record = await this.filesService.upload({ + resourceId: saved.id, + resource: FILE_RESOURCE, + code: FILE_RESOURCE, + file, + uploadedByUserId: staffId, + }); + await repo.update(saved.id, { fileRecordId: record.id }); + } + + if (shouldSend) { + saved = await this.issueInvoice(manager, saved.id, booking, staffId); + } + return saved.id; + }); + + if (shouldSend) await this.notifyCustomerSent(chargeId); + return this.list(bookingId); + } + + async send(bookingId: string, chargeId: string, staffId: string): Promise { + const charge = await this.findOwned(bookingId, chargeId); + if (charge.status !== 'DRAFT') { + throw new ConflictException('Only a draft charge can be sent.'); + } + const booking = await this.bookingsService.findById(bookingId); + + await this.dataSource.transaction((manager) => + this.issueInvoice(manager, charge.id, booking, staffId), + ); + await this.notifyCustomerSent(charge.id); + return this.list(bookingId); + } + + /** Issues the invoice and flips DRAFT → SENT. Notification happens after commit — never inside the transaction. */ + private async issueInvoice( + manager: EntityManager, + chargeId: string, + booking: { id: string; companyId?: string | null; companyProfileId?: string | null; reference?: string | null }, + staffId: string, + ): Promise { + const repo = manager.getRepository(AdditionalCharge); + const charge = await repo.findOneByOrFail({ id: chargeId }); + + const invoice = await this.billing.generateInvoice( + { + source: Freight.InvoiceSource.AdditionalCharge, + sourceId: charge.id, + type: 'ADDITIONAL_CHARGE', + 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', + description: `${charge.reason} — ${booking.reference ?? booking.id}`, + amount: Number(charge.amount), + }, + ], + }, + manager, + ); + + await repo.update(charge.id, { + status: 'SENT', + invoiceId: invoice.id, + sentByStaffId: staffId, + sentAt: new Date(), + }); + this.logger.log( + `Additional charge ${charge.id} on booking ${booking.id} sent as invoice ${invoice.invoiceNumber}`, + ); + return repo.findOneByOrFail({ id: charge.id }); + } + + private async notifyCustomerSent(chargeId: string): Promise { + try { + const charge = await this.repository.findById(chargeId); + if (!charge) return; + const booking = await this.bookingsService.findById(charge.bookingId); + if (!booking.companyId) return; + const body = `A new charge of ${charge.amount} ${charge.currency} has been added to booking ${booking.reference ?? charge.bookingId}: ${charge.reason}. Pay via the portal.`; + await this.inbox.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.INVOICE_ISSUED, + title: 'New charge on your booking', + body, + link: `/bookings/${charge.bookingId}`, + data: { + bookingId: charge.bookingId, + chargeId: charge.id, + amount: Number(charge.amount), + currency: charge.currency, + }, + }); + await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body); + } catch (err) { + this.logger.warn(`Additional charge sent-notify failed for ${chargeId}: ${(err as Error).message}`); + } + } + + async cancel( + bookingId: string, + chargeId: string, + staffId: string, + reason?: string, + ): Promise { + const charge = await this.findOwned(bookingId, chargeId); + if (charge.status !== 'DRAFT' && charge.status !== 'SENT') { + throw new ConflictException('Only a draft or unpaid charge can be cancelled.'); + } + if (charge.status === 'SENT' && charge.invoiceId) { + await this.billing.cancelInvoice(charge.invoiceId); + } + await this.repository.update(charge.id, { + status: 'CANCELLED', + cancelledByStaffId: staffId, + cancelledAt: new Date(), + cancelReason: reason ?? null, + }); + return this.list(bookingId); + } + + /** Gateway and manual settlements both land here (`${source}.invoice.paid`). */ + @OnEvent('additional_charge.invoice.paid') + async onChargeInvoicePaid(payload: InvoiceEventPayload): Promise { + const charge = await this.repository.findById(payload.sourceId); + if (!charge || charge.status === 'PAID') return; + await this.repository.update(charge.id, { status: 'PAID', paidAt: new Date() }); + + try { + const booking = await this.bookingsService.findById(charge.bookingId); + if (!booking.companyId) return; + const body = `Payment received for ${charge.amount} ${charge.currency} on booking ${booking.reference ?? charge.bookingId}: ${charge.reason}.`; + await this.inbox.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.PAYMENT_RECEIVED, + title: 'Charge payment received', + body, + link: `/bookings/${charge.bookingId}`, + data: { bookingId: charge.bookingId, chargeId: charge.id }, + }); + await this.inbox.notify({ + recipients: { permissionKeys: [FREIGHT_PERMS.additionalCharges.getNotification] }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.PAYMENT_RECEIVED, + title: 'Additional charge paid', + body, + link: `/bookings/${charge.bookingId}`, + data: { bookingId: charge.bookingId, chargeId: charge.id }, + }); + } catch (err) { + this.logger.warn(`Additional charge paid-notify failed for ${charge.id}: ${(err as Error).message}`); + } + } + + private async toDtoList(rows: AdditionalCharge[]): Promise { + if (!rows.length) return []; + + const filesByCharge = await this.filesService.findByResourceIdsGrouped( + rows.map((r) => r.id), + FILE_RESOURCE, + ); + const names = await this.bookingsRepository.resolveStaffNames( + rows.flatMap((r) => [r.createdByStaffId, r.sentByStaffId]), + ); + + const invoiceIds = rows.map((r) => r.invoiceId).filter((id): id is string => Boolean(id)); + const invoices = invoiceIds.length + ? 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, + reason: r.reason, + 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, + paymentReference: r.paymentReference ?? null, + createdByName: r.createdByStaffId ? (names.get(r.createdByStaffId) ?? null) : null, + createdAt: r.createdAt.toISOString(), + sentByName: r.sentByStaffId ? (names.get(r.sentByStaffId) ?? null) : null, + sentAt: r.sentAt?.toISOString() ?? null, + paidAt: r.paidAt?.toISOString() ?? null, + cancelledAt: r.cancelledAt?.toISOString() ?? null, + cancelReason: r.cancelReason ?? null, + }; + }); + } + + /** + * 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-clearance-charge.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts index f41bff39d..a1bceb844 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts @@ -14,9 +14,11 @@ import { Invoice } from '../billing/entities/invoice.entity'; import { FilesService } from '../files/files.service'; import { BookingsService } from './bookings.service'; import { BookingsRepository } from './bookings.repository'; +import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service'; import { Booking } from './entities/booking.entity'; import { BookingClearanceCharge, + ClearanceChargeStatus, ClearanceChargeType, } from './entities/booking-clearance-charge.entity'; import { ClearanceEventService } from './clearance-event.service'; @@ -32,13 +34,22 @@ const CHARGE_LABEL: Record = { MISCELLANEOUS: 'Miscellaneous charges', }; +/** Statuses the customer sees — drafts (DOC_UPLOADED / BILLED) stay GL-internal. */ +export const CUSTOMER_VISIBLE_CHARGE_STATUSES: ReadonlySet = + new Set(['SENT', 'REJECTED', 'ACCEPTED', 'PAID']); + +/** Once the customer has accepted (invoice issued) or paid, GL cannot touch the charge. */ +export const canStaffEditCharge = (status: ClearanceChargeStatus): boolean => + status !== 'ACCEPTED' && status !== 'PAID'; + /** - * Post-finalization clearance charges billed to the customer. Two levels per - * booking: GL Djibouti uploads the port-charges document; GL Ethiopia bills it - * (amount + currency) and sends the invoice; once that invoice is paid GL - * Ethiopia may create and send the miscellaneous charge. ETB invoices are paid - * through the portal gateway, other currencies through Finance's manual - * settlement worklist — both settle via `clearance_charge.invoice.paid`. + * Post-finalization clearance charges billed to the customer: one port charge + * (document from GL Djibouti, priced by GL Ethiopia) and any number of + * miscellaneous charges. GL prices + describes a charge and SENDs it; the + * customer REJECTs with a note (GL revises, re-sends) or ACCEPTs, which issues + * the payable invoice and locks the charge. ETB invoices are paid through the + * portal gateway, other currencies through Finance's manual settlement + * worklist — both settle via `clearance_charge.invoice.paid`. */ @Injectable() export class BookingClearanceChargeService { @@ -51,6 +62,7 @@ export class BookingClearanceChargeService { private readonly bookingsService: BookingsService, private readonly bookingsRepository: BookingsRepository, private readonly clearanceEvents: ClearanceEventService, + private readonly notifier: BookingLifecycleNotifierService, ) {} private repo() { @@ -104,6 +116,11 @@ export class BookingClearanceChargeService { file: file ? { id: file.id, name: file.name, url: file.url } : null, amount: c.amount != null ? Number(c.amount) : null, currency: c.currency ?? null, + description: c.description ?? null, + customerNote: c.customerNote ?? null, + customerDecidedAt: c.customerDecidedAt + ? c.customerDecidedAt.toISOString() + : null, invoiceId: c.invoiceId ?? null, invoiceNumber: c.invoiceId ? (invoiceById.get(c.invoiceId)?.invoiceNumber ?? null) @@ -121,6 +138,24 @@ export class BookingClearanceChargeService { }); } + /** The customer's view: only charges GL has sent them. */ + async listForCustomer(bookingId: string): Promise { + return (await this.list(bookingId)).filter((c) => + CUSTOMER_VISIBLE_CHARGE_STATUSES.has(c.status), + ); + } + + private async findCharge( + bookingId: string, + chargeId: string, + ): Promise { + const charge = await this.repo().findOne({ + where: { id: chargeId, bookingId }, + }); + if (!charge) throw new NotFoundException('Clearance charge not found'); + return charge; + } + /** GL Djibouti uploads (or replaces, until billed) the port-charges document. */ async uploadPortDocument( bookingId: string, @@ -180,22 +215,21 @@ export class BookingClearanceChargeService { } /** - * GL Ethiopia sets (or, on the customer's request, revises) amount + - * currency. Revising a SENT charge cancels its unpaid invoice; a PAID charge - * is immutable. + * GL Ethiopia sets (or, after a customer rejection, revises) amount + + * currency + description. Allowed until the customer accepts: an ACCEPTED + * charge already carries an invoice and a PAID one is settled. */ async billCharge( bookingId: string, chargeId: string, - input: { amount: number; currency: string }, + input: { amount: number; currency: string; description?: string }, staffId: string, ): Promise { - const charge = await this.repo().findOne({ - where: { id: chargeId, bookingId }, - }); - if (!charge) throw new NotFoundException('Clearance charge not found'); - if (charge.status === 'PAID') { - throw new ConflictException('A paid charge can no longer be changed.'); + const charge = await this.findCharge(bookingId, chargeId); + if (!canStaffEditCharge(charge.status)) { + throw new ConflictException( + 'The customer has accepted this charge — it can no longer be changed.', + ); } if (!(input.amount > 0)) { throw new BadRequestException('Amount must be greater than zero.'); @@ -203,53 +237,117 @@ export class BookingClearanceChargeService { if (!input.currency?.trim()) { throw new BadRequestException('Currency is required.'); } - - if (charge.status === 'SENT' && charge.invoiceId) { - await this.billing.cancelInvoice(charge.invoiceId); + const description = (input.description ?? charge.description ?? '').trim(); + if (charge.type === 'MISCELLANEOUS' && !description) { + throw new BadRequestException('Describe what this charge is for.'); } + const currency = input.currency.trim().toUpperCase(); + const revised = charge.status === 'SENT' || charge.status === 'REJECTED'; + // Back to draft: the customer's previous decision no longer applies. await this.repo().update(charge.id, { amount: input.amount.toFixed(2), - currency: input.currency.trim().toUpperCase(), + currency, + description: description || null, status: 'BILLED', - invoiceId: null, + customerNote: null, + customerDecidedAt: null, + customerDecidedBy: null, billedByStaffId: staffId, billedAt: new Date(), }); await this.clearanceEvents.record({ bookingId, action: 'CHARGE_BILLED', - label: `${charge.status === 'SENT' ? 'Revised' : 'Billed'} ${CHARGE_LABEL[ + label: `${revised ? 'Revised' : 'Billed'} ${CHARGE_LABEL[ charge.type - ].toLowerCase()}: ${input.amount} ${input.currency.trim().toUpperCase()}`, + ].toLowerCase()}: ${input.amount} ${currency}${ + description ? ` — ${description}` : '' + }`, actorId: staffId, metadata: { chargeType: charge.type, amount: input.amount, - currency: input.currency.trim().toUpperCase(), - revised: charge.status === 'SENT', + currency, + description: description || null, + revised, }, }); return this.list(bookingId); } - /** GL Ethiopia issues the payable invoice to the customer. */ + /** + * GL Ethiopia proposes the priced charge to the customer. No invoice yet — + * that is issued when the customer accepts. Re-sending after a rejection + * goes through here too. + */ async sendCharge( bookingId: string, chargeId: string, - staffId?: string, + staffId: string, ): Promise { - const charge = await this.repo().findOne({ - where: { id: chargeId, bookingId }, - }); - if (!charge) throw new NotFoundException('Clearance charge not found'); - if (charge.status !== 'BILLED') { + const charge = await this.findCharge(bookingId, chargeId); + if (charge.status !== 'BILLED' && charge.status !== 'REJECTED') { throw new ConflictException( - 'Set the amount and currency before sending the charge to the customer.', + charge.status === 'DOC_UPLOADED' + ? 'Set the amount and currency before sending the charge to the customer.' + : 'This charge has already been sent to the customer.', ); } + const revised = charge.status === 'REJECTED'; + const amount = Number(charge.amount); + const currency = charge.currency ?? 'ETB'; + await this.repo().update(charge.id, { + status: 'SENT', + customerNote: null, + customerDecidedAt: null, + customerDecidedBy: null, + }); + await this.clearanceEvents.record({ + bookingId, + action: 'CHARGE_SENT', + label: `${revised ? 'Re-sent' : 'Sent'} ${CHARGE_LABEL[ + charge.type + ].toLowerCase()} to the customer for approval: ${amount} ${currency}`, + actorId: staffId ?? null, + metadata: { + chargeType: charge.type, + amount, + currency, + description: charge.description ?? null, + revised, + }, + }); const booking = await this.bookingsService.findById(bookingId); + this.notifier.clearanceChargeProposed(booking, { + label: CHARGE_LABEL[charge.type], + amount, + currency, + description: charge.description ?? null, + revised, + }); + return this.list(bookingId); + } + + /** Customer agrees to the price: the payable invoice is issued and the charge locks. */ + async customerAccept( + bookingId: string, + chargeId: string, + userId: string, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + await this.bookingsService.assertCustomerCanAccessBooking(userId, booking); + const charge = await this.findCharge(bookingId, chargeId); + if (charge.status !== 'SENT' && charge.status !== 'REJECTED') { + throw new ConflictException( + charge.status === 'ACCEPTED' || charge.status === 'PAID' + ? 'This charge has already been accepted.' + : 'This charge is not awaiting your decision.', + ); + } + const amount = Number(charge.amount); + const currency = charge.currency ?? 'ETB'; const invoice = await this.billing.generateInvoice({ source: Freight.InvoiceSource.ClearanceCharge, // The charge's own id, NOT the booking id — booking-scoped invoice @@ -258,105 +356,156 @@ export class BookingClearanceChargeService { type: charge.type, companyId: booking.companyId, companyProfileId: booking.companyProfileId, - currency: charge.currency ?? 'ETB', + currency, lines: [ { chargeType: charge.type, - description: `${CHARGE_LABEL[charge.type]} — ${booking.reference ?? bookingId}`, - amount: Number(charge.amount), + description: `${CHARGE_LABEL[charge.type]} — ${ + booking.reference ?? bookingId + }${charge.description ? `: ${charge.description}` : ''}`, + amount, }, ], }); await this.repo().update(charge.id, { - status: 'SENT', + status: 'ACCEPTED', invoiceId: invoice.id, + customerNote: null, + customerDecidedAt: new Date(), + customerDecidedBy: userId, }); await this.clearanceEvents.record({ bookingId, - action: 'CHARGE_INVOICE_SENT', - label: `Sent ${CHARGE_LABEL[charge.type].toLowerCase()} invoice ${invoice.invoiceNumber} to the customer`, - actorId: staffId ?? null, + action: 'CHARGE_ACCEPTED', + label: `Customer accepted ${CHARGE_LABEL[ + charge.type + ].toLowerCase()} (${amount} ${currency}) — invoice ${invoice.invoiceNumber} issued`, + actorType: 'CUSTOMER', + actorId: userId, metadata: { chargeType: charge.type, invoiceNumber: invoice.invoiceNumber, - amount: Number(charge.amount), - currency: charge.currency, + amount, + currency, }, }); + this.notifier.clearanceChargeInvoiceIssued(booking, { + label: CHARGE_LABEL[charge.type], + amount, + currency, + invoiceNumber: invoice.invoiceNumber, + }); this.logger.log( - `Clearance charge ${charge.type} on booking ${bookingId} sent as invoice ${invoice.invoiceNumber}`, + `Clearance charge ${charge.type} on booking ${bookingId} accepted; invoice ${invoice.invoiceNumber}`, ); - return this.list(bookingId); + return this.listForCustomer(bookingId); + } + + /** Customer declines the price with a reason; GL revises and re-sends. */ + async customerReject( + bookingId: string, + chargeId: string, + note: string, + userId: string, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + await this.bookingsService.assertCustomerCanAccessBooking(userId, booking); + const charge = await this.findCharge(bookingId, chargeId); + if (charge.status !== 'SENT') { + throw new ConflictException( + charge.status === 'ACCEPTED' || charge.status === 'PAID' + ? 'This charge has already been accepted.' + : 'This charge is not awaiting your decision.', + ); + } + if (!note?.trim()) { + throw new BadRequestException('Say why you are rejecting this charge.'); + } + await this.repo().update(charge.id, { + status: 'REJECTED', + customerNote: note.trim(), + customerDecidedAt: new Date(), + customerDecidedBy: userId, + }); + await this.clearanceEvents.record({ + bookingId, + action: 'CHARGE_REJECTED', + label: `Customer rejected ${CHARGE_LABEL[charge.type].toLowerCase()}: ${note.trim()}`, + actorType: 'CUSTOMER', + actorId: userId, + metadata: { chargeType: charge.type, note: note.trim() }, + }); + this.notifier.clearanceChargeRejectedToStaff(booking, { + label: CHARGE_LABEL[charge.type], + note: note.trim(), + }); + return this.listForCustomer(bookingId); } /** - * GL Ethiopia creates the miscellaneous charge whole (document + amount + - * currency). Second payment level: allowed only once the port charge is paid. + * GL Ethiopia creates a miscellaneous charge whole (document + amount + + * currency + what it is for). Lands as a BILLED draft; GL sends it next. */ async createMiscellaneous( bookingId: string, file: Express.Multer.File, - input: { amount: number; currency: string }, + input: { amount: number; currency: string; description?: string }, staffId: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); this.assertClearanceFinalized(booking); - const port = await this.repo().findOne({ - where: { bookingId, type: 'PORT_CHARGES' }, - }); - if (port?.status !== 'PAID') { - throw new ConflictException( - 'Miscellaneous charges open after the port charge is paid.', - ); - } - const existing = await this.repo().findOne({ - where: { bookingId, type: 'MISCELLANEOUS' }, - }); - if (existing) { - throw new ConflictException( - 'This booking already has a miscellaneous charge — revise it instead.', - ); - } + // No ordering and no cap: a miscellaneous charge may be raised before, + // after or alongside the port charge, and a booking may carry several. if (!(input.amount > 0)) { throw new BadRequestException('Amount must be greater than zero.'); } if (!input.currency?.trim()) { throw new BadRequestException('Currency is required.'); } + const description = input.description?.trim() ?? ''; + if (!description) { + throw new BadRequestException('Describe what this charge is for.'); + } - const record = await this.filesService.upsertByCode( - { - resourceId: bookingId, - resource: 'bookings', - code: CHARGE_FILE_CODE.MISCELLANEOUS, - file, - }, - { userId: staffId }, - ); - await this.repo().save( + // Save the row first so its id can key the document. A booking may carry + // several miscellaneous charges, and `upsertByCode` retires whatever sits + // under the same code — a shared code would silently delete the previous + // charge's document. + const charge = await this.repo().save( this.repo().create({ bookingId, type: 'MISCELLANEOUS', status: 'BILLED', - fileRecordId: record.id, amount: input.amount.toFixed(2), currency: input.currency.trim().toUpperCase(), + description, uploadedByStaffId: staffId, uploadedAt: new Date(), billedByStaffId: staffId, billedAt: new Date(), }), ); + const record = await this.filesService.upsertByCode( + { + resourceId: bookingId, + resource: 'bookings', + code: `${CHARGE_FILE_CODE.MISCELLANEOUS}_${charge.id}`, + file, + }, + { userId: staffId }, + ); + await this.repo().update(charge.id, { fileRecordId: record.id }); await this.clearanceEvents.record({ bookingId, action: 'CHARGE_MISC_CREATED', - label: `Created miscellaneous charge: ${input.amount} ${input.currency.trim().toUpperCase()}`, + label: `Created miscellaneous charge: ${input.amount} ${input.currency.trim().toUpperCase()} — ${description}`, actorId: staffId, metadata: { amount: input.amount, currency: input.currency.trim().toUpperCase(), + description, fileName: file.originalname, }, }); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.spec.ts new file mode 100644 index 000000000..ac1a5a225 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.spec.ts @@ -0,0 +1,22 @@ +import { + CUSTOMER_VISIBLE_CHARGE_STATUSES, + canStaffEditCharge, +} from './booking-clearance-charge.service'; +import { CLEARANCE_CHARGE_STATUSES } from './entities/booking-clearance-charge.entity'; + +describe('clearance charge status guards', () => { + it('locks the charge once the customer has accepted or paid', () => { + expect(canStaffEditCharge('ACCEPTED')).toBe(false); + expect(canStaffEditCharge('PAID')).toBe(false); + for (const s of ['DOC_UPLOADED', 'BILLED', 'SENT', 'REJECTED'] as const) { + expect(canStaffEditCharge(s)).toBe(true); + } + }); + + it('hides GL drafts from the customer and shows everything sent', () => { + const visible = CLEARANCE_CHARGE_STATUSES.filter((s) => + CUSTOMER_VISIBLE_CHARGE_STATUSES.has(s), + ); + expect(visible).toEqual(['SENT', 'REJECTED', 'ACCEPTED', 'PAID']); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index 78c07bd22..ad0e62ef3 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -202,6 +202,17 @@ export class BookingLifecycleNotifierService { } /** A clearance document was queried and needs the customer to re-upload. */ + /** GL asked the customer for additional clearance document(s). */ + additionalDocsRequested(b: Booking, note: string): void { + const msg = + `Additional document(s) requested on booking ${b.reference}: ` + + `${note} Please upload them from the portal.`; + void this.notifyContact(b, msg, 'ADDITIONAL DOCUMENTS REQUESTED'); + this.inApp(b, 'Additional documents requested', msg, { + type: NotificationType.DOCUMENT_ACTION, + }); + } + documentQueried(b: Booking, fileKey: string, note: string): void { const msg = `A clearance document on booking ${b.reference} needs attention: "${fileKey}". ` + @@ -410,6 +421,55 @@ export class BookingLifecycleNotifierService { }); } + // ── Clearance charges (port + miscellaneous) ─────────────────────────────── + + /** GL proposed (or re-proposed) a clearance charge — the customer accepts or rejects it in the portal. */ + clearanceChargeProposed( + b: Booking, + c: { + label: string; + amount: number; + currency: string; + description: string | null; + revised: boolean; + }, + ): void { + const msg = + `${c.revised ? 'Revised ' + c.label.toLowerCase() : c.label} of ${c.amount} ${c.currency}` + + `${c.description ? ` (${c.description})` : ''} on booking ${b.reference} ` + + `await your approval. Please accept or reject them in the portal.`; + void this.notifyContact(b, msg, c.revised ? 'CLEARANCE CHARGE REVISED' : 'CLEARANCE CHARGE SENT'); + this.inApp(b, c.revised ? `${c.label} revised` : `${c.label} need your approval`, msg, { + type: NotificationType.INVOICE_ISSUED, + }); + } + + /** The customer accepted a clearance charge — its invoice is now payable. */ + clearanceChargeInvoiceIssued( + b: Booking, + c: { label: string; amount: number; currency: string; invoiceNumber: string }, + ): void { + const msg = + `Invoice ${c.invoiceNumber} for ${c.label.toLowerCase()} (${c.amount} ${c.currency}) ` + + `on booking ${b.reference} is ready. Please pay it from the portal.`; + void this.notifyContact(b, msg, 'CLEARANCE CHARGE INVOICE'); + this.inApp(b, `${c.label} invoice issued`, msg, { + type: NotificationType.INVOICE_ISSUED, + }); + } + + /** The customer rejected a clearance charge — GL Ethiopia revises and re-sends. */ + clearanceChargeRejectedToStaff(b: Booking, c: { label: string; note: string }): void { + const msg = + `The customer rejected the ${c.label.toLowerCase()} on booking ${this.ref(b)}: ` + + `"${c.note}". Revise and re-send from the clearance page.`; + this.inAppStaff(b, `${c.label} rejected — ${this.ref(b)}`, msg, { + recipients: CLEARANCE_DESK, + type: NotificationType.CLEARANCE_REVIEW, + link: `/dashboard/clearance/${b.id}`, + }); + } + /** GL confirmed the final-invoice payment slip. */ finalInvoicePaid(b: Booking): void { const msg = `Your final invoice payment for booking ${b.reference} has been confirmed. Thank you.`; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-payables.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-payables.service.ts new file mode 100644 index 000000000..e815fe929 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-payables.service.ts @@ -0,0 +1,107 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { Freight } from '@edr/types'; + +/** Invoice statuses a customer can still settle (mirrors the portal's PAYABLE_STATUSES). */ +const PAYABLE_INVOICE_STATUSES = ['ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE']; +/** Booking statuses at which the freight invoice is actually due (mirrors BookingsService). */ +const FREIGHT_PAYABLE_BOOKING_STATUSES = [ + 'FULLY_EXECUTED', + 'SELECTED_FOR_BATCH', + 'AWAITING_PAYMENT', +]; + +/** + * One row per outstanding item. `invoices.status` / `bookings.status` are + * Postgres enums, hence the ::text casts. `amount` is NULL for items that only need the + * customer's review (a proposed clearance charge, a draft final invoice) so + * they count but do not inflate "amount due". + */ +const SQL = ` + -- Central invoices on the booking: freight (only while the booking is in a + -- payable status), wagon-cancellation fee, GL final invoice (+ its DRAFT, + -- which waits for the customer's approval). + SELECT i.source_id AS "bookingId", i.currency, + CASE WHEN i.status::text = 'DRAFT' THEN NULL ELSE i.balance_amount END AS amount + FROM freight.invoices i + JOIN freight.bookings b ON b.id::text = i.source_id AND b.deleted_at IS NULL + WHERE i.company_id = $1 AND i.deleted_at IS NULL AND i.source = 'booking' + AND ( + (i.status::text = ANY($2::text[]) AND i.balance_amount > 0 + AND (i.type IN ('WAGON_CANCEL_FEE', 'GL_FINAL') OR b.status::text = ANY($3::text[]))) + OR (i.type = 'GL_FINAL' AND i.status::text = 'DRAFT') + ) + UNION ALL + -- Accepted clearance charges whose invoice is still unpaid. + SELECT c.booking_id::text, i.currency, i.balance_amount + FROM freight.invoices i + JOIN freight.booking_clearance_charge c ON c.id::text = i.source_id AND c.deleted_at IS NULL + WHERE i.company_id = $1 AND i.deleted_at IS NULL AND i.source = 'clearance_charge' + AND i.status::text = ANY($2::text[]) AND i.balance_amount > 0 + UNION ALL + -- Clearance charges waiting for the customer to accept or reject the price. + SELECT c.booking_id::text, c.currency, NULL::numeric + FROM freight.booking_clearance_charge c + JOIN freight.bookings b ON b.id = c.booking_id AND b.deleted_at IS NULL + WHERE b.company_id = $1 AND c.deleted_at IS NULL AND c.status = 'SENT' + UNION ALL + -- Duty / tax advised by customs, payment slip not uploaded yet. + SELECT m.booking_id::text, m.metadata->>'dutyCurrency', + NULLIF(m.metadata->>'dutyAmount', '')::numeric + FROM freight.clearance_milestones m + JOIN freight.bookings b ON b.id = m.booking_id AND b.deleted_at IS NULL + WHERE b.company_id = $1 AND m.deleted_at IS NULL AND m.status = 'COMPLETED' + AND ( + (m.milestone_code = 'DUTY_TAXES_ADVISED' AND NOT EXISTS ( + SELECT 1 FROM freight.clearance_milestones p + WHERE p.booking_id = m.booking_id AND p.milestone_code = 'DUTY_TAX_PAID' + AND p.status = 'COMPLETED' AND p.deleted_at IS NULL)) + OR + (m.milestone_code = 'SECOND_DUTY_ADVISED' AND NOT EXISTS ( + SELECT 1 FROM freight.clearance_milestones p + WHERE p.booking_id = m.booking_id AND p.milestone_code = 'SECOND_DUTY_PAID' + AND p.status = 'COMPLETED' AND p.deleted_at IS NULL)) + ) +`; + +/** + * Everything a customer still has to act on, per booking, in one query. Drives + * the "Pay" badge on the home and booking-list rows; the booking's Payments tab + * composes the same items client-side from the per-booking endpoints. + */ +@Injectable() +export class BookingPayablesService { + constructor(private readonly dataSource: DataSource) {} + + async summarizeForCompany( + companyId: string, + ): Promise { + const rows: Array<{ + bookingId: string; + currency: string | null; + amount: string | null; + }> = await this.dataSource.query(SQL, [ + companyId, + PAYABLE_INVOICE_STATUSES, + FREIGHT_PAYABLE_BOOKING_STATUSES, + ]); + + const byBooking = new Map(); + for (const r of rows) { + const s = byBooking.get(r.bookingId) ?? { + bookingId: r.bookingId, + count: 0, + totals: [], + }; + s.count += 1; + const amount = Number(r.amount ?? 0); + if (r.currency && amount > 0) { + const t = s.totals.find((x) => x.currency === r.currency); + if (t) t.amount += amount; + else s.totals.push({ currency: r.currency, amount }); + } + byBooking.set(r.bookingId, s); + } + return [...byBooking.values()]; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index 75a179b5c..ba1aaa875 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -57,6 +57,7 @@ describe('BookingPricingService — domestic corridor', () => { exchangeService as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, {} as never, + { findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never, ); }); @@ -333,6 +334,7 @@ describe('BookingPricingService — customs clearance fee billed on the booking : [], }), } as never, + { findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never, ); const containerBooking = (overrides: Record = {}) => @@ -388,6 +390,30 @@ describe('BookingPricingService — customs clearance fee billed on the booking expect(line!.amount).toBe(200); }); + it('prices an Ethiopian-customs-only service off ETHIOPIAN_CUSTOMS_CLEARANCE, not the full fee', async () => { + const ethiopianFee = { + ...containerFee20, + id: 'rate-et-20', + rateType: 'ETHIOPIAN_CUSTOMS_CLEARANCE', + trigger: 'ETHIOPIAN_CUSTOMS_CLEARANCE', + rateValue: 40, + } as Rate; + // No serviceType relation on the booking (like the GL/portal shipment + // preview) — the flag must be resolved from serviceTypeId. + const service = makeService({ liveRates: [containerFee20, ethiopianFee] }); + (service as unknown as { serviceTypesService: { findById: jest.Mock } }).serviceTypesService = { + findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: true }), + }; + const result = await service.computePriceForBooking( + containerBooking({ serviceTypeId: 'st-et', serviceType: undefined }), + ); + + const line = result.lineItems.find((l) => l.code === 'ETHIOPIAN_CUSTOMS_CLEARANCE_20FT'); + expect(line).toBeDefined(); + expect(line!.amount).toBe(160); + expect(result.lineItems.some((l) => l.code === 'CUSTOMS_CLEARANCE_20FT')).toBe(false); + }); + it('hard-blocks a container type with no fee configured (never free clearance)', async () => { const service = makeService({ liveRates: [bulkFeePerTon] }); const result = await service.computePriceForBooking(containerBooking()); @@ -553,6 +579,7 @@ describe('BookingPricingService — bulk base freight units', () => { wagonTypes: wagonCapacity !== undefined ? [{ capacityTons: wagonCapacity }] : [], }), } as never, + { findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never, ); // 12 machines, not 12 tonnes — a PER_ITEM commodity records its count here. @@ -683,6 +710,7 @@ describe('BookingPricingService — PER_WAGON container freight', () => { { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, { findById: jest.fn() } as never, + { findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never, ); const booking = ( diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index f7dc40a82..49732d2df 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -3,6 +3,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { CargoTypesService } from '../rule-engine/services/cargo-types.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RatesService } from '../rule-engine/services/rates.service'; +import { ServiceTypesService } from '../rule-engine/services/service-types.service'; import { Rate } from '../rule-engine/entities/rate.entity'; import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util'; import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; @@ -84,6 +85,7 @@ export class BookingPricingService { private readonly exchangeService: ExchangeService, private readonly containerValidationService: ContainerValidationService, private readonly cargoTypesService: CargoTypesService, + private readonly serviceTypesService: ServiceTypesService, ) {} async generatePrice(bookingId: string): Promise { @@ -1060,9 +1062,27 @@ export class BookingPricingService { const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd); + // An Ethiopian-side-only customs service prices off its own rate; the + // contract froze its snapshots under the matching code prefix. Resolved by + // id when the relation isn't loaded — the GL / portal shipment previews + // price a transient booking object, and a missing relation must not + // silently quote the standard fee the created booking is then billed + // differently for. + const serviceType = + booking.serviceType ?? + (booking.serviceTypeId + ? await this.serviceTypesService.findById(booking.serviceTypeId).catch(() => null) + : null); + const customsType = serviceType?.includesEthiopianCustomsOnly + ? 'ETHIOPIAN_CUSTOMS_CLEARANCE' + : 'CUSTOMS_CLEARANCE'; + const customsLabel = + customsType === 'ETHIOPIAN_CUSTOMS_CLEARANCE' + ? 'Ethiopian customs clearance service' + : 'Customs clearance service'; const onLeg = liveRates.filter( (r) => - r.rateType === 'CUSTOMS_CLEARANCE' && + r.rateType === customsType && r.currency === 'USD' && r.tradeDirection === booking.tradeDirection && r.originYardId === booking.originYardId && @@ -1070,20 +1090,20 @@ export class BookingPricingService { ); const missingRateMessage = (scope: string): string => `No customs clearance service fee is configured for ${scope} on this ` + - 'origin → destination. Ask EDR to configure the CUSTOMS_CLEARANCE rate for this route.'; + `origin → destination. Ask EDR to configure the ${customsType} rate for this route.`; if (booking.freightType === 'CONTAINER') { // Legacy short-circuit: an old contract froze one flat fee — bill it once. const hasPerSizeSnapshot = - frozenRates?.has('CUSTOMS_CLEARANCE_20FT') || - frozenRates?.has('CUSTOMS_CLEARANCE_40FT'); - const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb); + frozenRates?.has(`${customsType}_20FT`) || + frozenRates?.has(`${customsType}_40FT`); + const legacyFlat = this.frozenRateByCode(frozenRates, customsType, currency, usdToEtb); if (legacyFlat && !hasPerSizeSnapshot) { const amount = Number(legacyFlat.unitPrice); if (amount > 0) { lineItems.push({ - code: 'CUSTOMS_CLEARANCE', - description: 'Customs clearance service', + code: customsType, + description: customsLabel, amount, unitAmount: amount, unit: 'FLAT', @@ -1106,7 +1126,7 @@ export class BookingPricingService { // unknown type — falls through to the live per-type lookup below } const frozen = sizeFt - ? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency, usdToEtb) + ? this.frozenRateByCode(frozenRates, `${customsType}_${sizeFt}FT`, currency, usdToEtb) : null; const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId); if (!frozen && !live) { @@ -1124,8 +1144,8 @@ export class BookingPricingService { const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty; if (!(amount > 0)) continue; lineItems.push({ - code: sizeFt ? `CUSTOMS_CLEARANCE_${sizeFt}FT` : 'CUSTOMS_CLEARANCE', - description: `Customs clearance service${sizeFt ? ` (${sizeFt}ft)` : ''}`, + code: sizeFt ? `${customsType}_${sizeFt}FT` : customsType, + description: `${customsLabel}${sizeFt ? ` (${sizeFt}ft)` : ''}`, amount, unitAmount, unit, @@ -1141,7 +1161,7 @@ export class BookingPricingService { // flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee. // Live lookup: the rate scoped to the booking's commodity wins; a // commodity-less rate (legacy) is the catch-all fallback. - const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb); + const frozen = this.frozenRateByCode(frozenRates, customsType, currency, usdToEtb); const live = (booking.cargoTypeId ? onLeg.find( @@ -1172,8 +1192,8 @@ export class BookingPricingService { const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty; if (amount > 0) { lineItems.push({ - code: 'CUSTOMS_CLEARANCE', - description: 'Customs clearance service (bulk)', + code: customsType, + description: `${customsLabel} (bulk)`, amount, unitAmount, unit, 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 805b2216f..264ffb811 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 @@ -28,6 +28,7 @@ import { ContainerValidationService } from './container-validation.service'; import { BookingsRepository } from './bookings.repository'; import { assertBookingStatus } from './booking-status.util'; import { + adHocLabel, clearanceCodesForBooking, clearanceDocumentsOpen, } from './clearance.util'; @@ -90,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) { @@ -455,11 +438,44 @@ 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 + // cannot board alone, so the partnerLapsed listener cancels it too, with + // the cancellation fee — this unpaid canceller owes nothing (fees only + // apply to paid bookings). + 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", { + paidBookingId: partnerId, + }); + } 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, @@ -513,6 +529,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": @@ -524,11 +551,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, @@ -565,8 +587,54 @@ 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 cannot board alone, so the partnerLapsed listener cancels + // it too, with the cancellation fee — the unpaid canceller owes nothing + // (fees only apply to paid bookings). 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", { + paidBookingId: partnerId, + }); + } 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, @@ -643,6 +711,12 @@ export class BookingTransitionService { }>; allApproved: boolean; documentsOpen: boolean; + docRequests: Array<{ + id: string; + note: string; + byName: string | null; + at: string; + }>; phase?: string | null; milestones?: unknown[]; nextAction?: unknown; @@ -674,9 +748,14 @@ export class BookingTransitionService { bookingId, "CHANGES_REQUESTED", ); + const docRequestNotes = await this.bookingsRepository.findReviewNotes( + bookingId, + "ADDITIONAL_DOC_REQUEST", + ); const reviewerNames = await this.bookingsRepository.resolveStaffNames([ ...reviews.map((r) => r.reviewedByStaffId), ...queryNotes.map((n) => n.authorId), + ...docRequestNotes.map((n) => n.authorId), ]); const documents: Awaited< @@ -731,7 +810,9 @@ export class BookingTransitionService { const review = reviewByKey.get(`custom:${f.code}`) ?? null; documents.push({ fileKey: f.code, - label: f.name, + // What the customer called it, falling back to the filename for rows + // uploaded before the name was carried through. + label: f.title || adHocLabel(f.code) || f.name, required: false, uploadedBy: "customer", settingCode: "custom", @@ -763,9 +844,51 @@ export class BookingTransitionService { documents, allApproved, documentsOpen: clearanceDocumentsOpen(booking), + docRequests: docRequestNotes.map((n) => ({ + id: n.id, + note: n.note, + byName: n.authorId ? (reviewerNames.get(n.authorId) ?? null) : null, + at: n.createdAt.toISOString(), + })), }; } + /** + * GL asks the customer for additional clearance document(s). Stored as a + * review-note thread shown on both the GL clearance page and the customer's + * portal; the customer answers with an ad-hoc upload. Allowed for as long as + * documents are open (until the shipment is paid). + */ + async requestAdditionalDocuments( + bookingId: string, + note: string, + staffId: string, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + if (!clearanceDocumentsOpen(booking)) { + throw new ConflictException( + `Clearance documents are closed for this booking (status "${booking.status}").`, + ); + } + if (!note?.trim()) { + throw new BadRequestException("Describe the document(s) you need."); + } + await this.bookingsRepository.createReviewNote( + bookingId, + note.trim(), + "ADDITIONAL_DOC_REQUEST", + staffId, + ); + await this.clearanceEvents.record({ + bookingId, + action: "ADDITIONAL_DOCS_REQUESTED", + label: "Requested additional document(s) from the customer", + actorId: staffId, + metadata: { note: note.trim() }, + }); + this.notifier.additionalDocsRequested(booking, note.trim()); + } + /** * True when every REQUIRED field of the booking's customer-input clearance set * has an APPROVED review row. The 100% gate before clearance can be finalized. @@ -837,6 +960,10 @@ export class BookingTransitionService { resource: "bookings", code: file.fieldname, file, + // Ad-hoc uploads carry the name the customer typed (fieldname + // `custom_