Merge branch 'dev' into freight/nati-2

This commit is contained in:
Nathnael
2026-08-24 08:38:37 +00:00
225 changed files with 19460 additions and 4235 deletions

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

Binary file not shown.

View File

@@ -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 <workbook.xlsx>
# 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).

View File

@@ -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:*",

View File

@@ -0,0 +1,339 @@
/**
* 10 scenarios against the REAL S-2026-00045 consist (42 NW5 + 10 PW2) on the
* real DCT -> DIRE_DAWA -> GMP corridor, checking the three reported issues:
*
* 1. LEG BOARD truthfulness — can you tell which booking rides which leg?
* 2. CONSIST DIAGRAM — does a shared wagon expose one row per load?
* 3. PARTIAL OFFER — when only the 10 PW2 are free, is the customer offered
* the part that fits instead of being dropped silently?
*
* Read-only. Run: npx ts-node -T scripts/run-legboard-tests.ts
*/
import { DataSource } from 'typeorm';
import {
planWagonsWithStock,
type AllowedWagonTypeMap,
type WagonStock,
} from '../src/modules/train-scheduling/wagon-plan-flex.util';
import { validateWagonCargoExclusivity } from '../src/modules/train-scheduling/utils/wagon-plan.util';
import { sizePartialOfferWagons, bulkTonsPerWagon } from '../src/modules/train-scheduling/train-capacity.util';
import type { Booking } from '../src/modules/bookings/entities/booking.entity';
import type { WagonType } from '../src/modules/wagon-types/entities/wagon-type.entity';
const YARD = {
DCT: 'fc558b95-da28-4fc3-8348-311a290c34ae',
DIRE: 'f7b1686f-d43e-42aa-bc6a-d3d5849296c9',
GMP: '61ae1e66-c229-4dcd-9851-b2b9424f3a95',
};
const STOPS = [YARD.DCT, YARD.DIRE, YARD.GMP];
const STOP_NAME = ['DCT', 'DIRE', 'GMP'];
const NW5 = '8f717b09-eec1-46ad-be3d-2dc0a56e55e7';
const PW2 = '8eec3a7d-8482-4397-96b6-59a028210722';
const PERISHABLE = 'a5991d3a-d690-4b7e-98fd-ea3333aa16e7';
const FT40 = '349072e7-8a90-4c03-b682-08976abfd7e8';
const ds = new DataSource({
type: 'postgres',
host: process.env.DB_HOST || '10.18.7.207',
port: Number(process.env.DB_PORT || 5432),
database: process.env.DB_NAME || 'edr_dev',
username: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'dcba@1234',
});
type Check = { name: string; pass: boolean; detail: string };
const groups: Array<{ scenario: string; checks: Check[] }> = [];
const add = (l: Check[], name: string, pass: boolean, detail: string) =>
l.push({ name, pass, detail });
let TYPES: WagonType[] = [];
let ALLOWED: AllowedWagonTypeMap;
let CODES = new Map<string, string>();
let CARGO: any;
async function loadConfig() {
TYPES = await ds.query(
`SELECT id, code, name, capacity_tons AS "capacityTons",
length_meters AS "lengthMeters", tare_weight_tons AS "tareWeightTons",
supports_container AS "supportsContainer", is_active AS "isActive"
FROM freight.wagon_types WHERE is_active IS NOT FALSE`);
const byId = new Map(TYPES.map((t) => [t.id, t]));
CODES = new Map(TYPES.map((t) => [t.id, t.code]));
const rows = async (sql: string) => {
const r: Array<{ typeId: string; wagonTypeId: string }> = await ds.query(sql);
const m = new Map<string, WagonType[]>();
for (const x of r) {
const wt = byId.get(x.wagonTypeId);
if (wt) m.set(x.typeId, [...(m.get(x.typeId) ?? []), wt]);
}
return m;
};
ALLOWED = {
byCargoTypeId: await rows(
`SELECT cargo_type_id AS "typeId", wagon_type_id AS "wagonTypeId" FROM freight.cargo_type_wagon_types`),
byContainerTypeId: await rows(
`SELECT container_type_id AS "typeId", wagon_type_id AS "wagonTypeId" FROM freight.container_type_wagon_types`),
};
const [c] = await ds.query(
`SELECT id, cargo_type_name AS "cargoTypeName", code, unit_of_measure AS "unitOfMeasure",
tons_per_wagon_map AS "tonsPerWagonMap", items_per_wagon_map AS "itemsPerWagonMap"
FROM freight.cargo_types WHERE id=$1`, [PERISHABLE]);
CARGO = { ...c, wagonTypes: ALLOWED.byCargoTypeId.get(PERISHABLE) ?? [] };
}
let seq = 0;
const bulk = (ref: string, tons: number, from: string, to: string, paid = false): Booking =>
({
id: `bulk-${(seq += 1)}`, reference: ref, freightType: 'BULK',
cargoTotalWeightVgm: tons, cargoTypeId: PERISHABLE, cargoType: CARGO,
originYardId: from, destinationYardId: to, bookingContainers: [],
status: paid ? 'PAID' : 'CLEARANCE_READY',
paymentStatus: paid ? 'PAID' : 'PENDING',
}) as unknown as Booking;
const cont = (ref: string, qty: number, from: string, to: string, paid = false): Booking =>
({
id: `cont-${(seq += 1)}`, reference: ref, freightType: 'CONTAINER',
cargoTotalWeightVgm: qty * 26, originYardId: from, destinationYardId: to,
status: paid ? 'PAID' : 'CLEARANCE_READY',
paymentStatus: paid ? 'PAID' : 'PENDING',
bookingContainers: [{
id: `line-${seq}`, containerTypeId: FT40, quantity: qty,
vgmPerUnitTons: 26, wagonsRequired: qty,
containerType: { id: FT40, code: '40FT', sizeFt: 40 }, units: [],
}],
}) as unknown as Booking;
const stockOf = (nw5: number, pw2: number): WagonStock => ({
mode: 'TRAIN',
remainingByTypeId: new Map([[NW5, nw5], [PW2, pw2]]),
codesByTypeId: CODES,
});
const legsOf = (bs: Booking[]) =>
new Map(bs.map((b: any) => [b.id,
{ from: STOPS.indexOf(b.originYardId), to: STOPS.indexOf(b.destinationYardId) }]));
const plan = (bs: Booking[], stock: WagonStock) =>
planWagonsWithStock({
bookings: bs, allowed: ALLOWED, stock, legs: legsOf(bs),
edgeCount: 2, stops: STOPS,
});
const byType = (p: any[]) => {
const c = new Map<string, number>();
for (const s of p) c.set(s.wagonTypeCode, (c.get(s.wagonTypeCode) ?? 0) + 1);
return [...c.entries()].map(([k, n]) => `${n}x ${k}`).join(', ') || 'none';
};
/**
* What the LEG BOARD can actually say about a slot, given only the fields it
* reads (boardYardId / alightYardId) — this is the UI's own view.
*/
const stampSpan = (slot: any, bs: Booking[]) => {
const byId = new Map(bs.map((b: any) => [b.id, b]));
const sb = [...new Set(slot.allocations.map((a: any) => a.bookingId))]
.map((id) => byId.get(id as string)).filter(Boolean) as any[];
if (!sb.length) return null;
const first = sb[0];
const same = sb.every((b) =>
b.originYardId === first.originYardId && b.destinationYardId === first.destinationYardId);
if (same) {
return {
from: STOPS.indexOf(first.originYardId),
to: STOPS.indexOf(first.destinationYardId),
union: false,
};
}
let from = Infinity, to = -Infinity;
for (const b of sb) {
from = Math.min(from, STOPS.indexOf(b.originYardId));
to = Math.max(to, STOPS.indexOf(b.destinationYardId));
}
return { from, to, union: true };
};
async function main() {
await ds.initialize();
await loadConfig();
console.log('\n=== REAL CONSIST (S-2026-00045): 42 NW5 + 10 PW2 ===');
console.log('Corridor: DCT -> DIRE -> GMP (edge 0 = DCT->DIRE, edge 1 = DIRE->GMP)\n');
// ============================================================ SCENARIO 1
// The reported shape: 42x40ft intercity Dire->GMP + bulk DCT->GMP.
{
const checks: Check[] = [];
const bs = [cont('IC-42', 42, YARD.DIRE, YARD.GMP), bulk('BULK-695', 695, YARD.DCT, YARD.GMP, true)];
const r = plan(bs, stockOf(42, 10));
console.log('── S1: 42x40ft Dire->GMP (intercity) + 695T bulk DCT->GMP (paid)');
console.log(` plan ${r.plan.length} wagons — ${byType(r.plan)}`);
for (const d of r.deferred) console.log(` DEFERRED ${d.reference}: ${d.reason}`);
// Every slot must say, truthfully, which legs it is busy on.
const shared = r.plan.filter((s) => {
const refs = new Set(s.allocations.map((a) => a.bookingReference));
return refs.size > 1;
});
add(checks, 'shared wagons expose EVERY load (diagram can stack them)',
shared.every((s) => s.allocations.length >= 2),
`${shared.length} shared wagon(s); allocations per shared wagon: ${
shared.map((s) => s.allocations.length).join(',') || 'n/a'}`);
// The leg-board reads ONE span per slot. For a mixed-corridor wagon that
// span is the UNION, which cannot say which load rides which leg.
const mixed = r.plan.map((s) => stampSpan(s, bs)).filter((x) => x?.union);
add(checks, 'no wagon needs a UNION span (leg board stays truthful)',
mixed.length === 0,
mixed.length
? `${mixed.length} wagon(s) carry different corridors -> leg board shows one merged bar`
: 'every wagon carries a single corridor');
add(checks, 'per-edge exclusivity holds',
validateWagonCargoExclusivity(r.plan, legsOf(bs), 2).length === 0, 'clean');
groups.push({ scenario: 'S1 — intercity 42x40ft + paid bulk (the reported case)', checks });
}
// ============================================================ SCENARIO 2
// Only the 10 PW2 are free — the staging complaint.
{
const checks: Check[] = [];
const b = bulk('BULK-695', 695, YARD.DCT, YARD.GMP, true);
const r = plan([b], stockOf(0, 10));
console.log('\n── S2: 695T bulk, ONLY 10 PW2 free (0 NW5)');
console.log(` plan ${r.plan.length} wagons — ${byType(r.plan)}`);
for (const d of r.deferred) console.log(` DEFERRED ${d.reference}: ${d.reason}`);
add(checks, 'whole booking correctly refused (10 PW2 x 20T = 200T < 695T)',
r.deferred.length === 1 && r.fitting.length === 0,
r.deferred[0]?.reason ?? 'unexpectedly fitted');
// What SHOULD happen: offer the part that fits on those 10 PW2.
const perWagonTons = bulkTonsPerWagon(CARGO, PW2, 70);
const pw2Type = TYPES.find((t) => t.id === PW2)!;
const wholeWagons = Math.ceil(695 / perWagonTons);
const offer = sizePartialOfferWagons(
{ wagons: 10, weightTons: 3500, lengthMeters: 1520 },
wholeWagons,
{ capacityTons: perWagonTons,
tareWeightTons: Number(pw2Type.tareWeightTons),
lengthMeters: Number(pw2Type.lengthMeters) },
{ fullWagonsOnly: true },
);
console.log(` partial offer sizing: ${offer ? `${offer.wagons} PW2 = ${offer.maxCargoTons}T of 695T` : 'NONE'}`);
add(checks, 'a partial offer of 10 PW2 (200T) is sizeable for the customer',
offer != null && offer.wagons === 10 && offer.maxCargoTons === 200,
offer ? `${offer.wagons} PW2 x ${perWagonTons}T = ${offer.maxCargoTons}T` : 'no offer could be sized');
groups.push({ scenario: 'S2 — only 10 PW2 free: split offer instead of silent drop', checks });
}
// ============================================================ SCENARIO 3-10
const cases: Array<{ name: string; bookings: Booking[]; stock: WagonStock; expectFit?: string[] }> = [
{ name: 'S3 — bulk DCT->DIRE + bulk DIRE->GMP (disjoint, reuse expected)',
bookings: [bulk('B-L1', 200, YARD.DCT, YARD.DIRE), bulk('B-L2', 200, YARD.DIRE, YARD.GMP)],
stock: stockOf(7, 0) },
{ name: 'S4 — container DCT->DIRE then bulk DIRE->GMP on same wagons',
bookings: [cont('C-L1', 7, YARD.DCT, YARD.DIRE), bulk('B-L2', 210, YARD.DIRE, YARD.GMP)],
stock: stockOf(7, 0) },
{ name: 'S5 — three corridors at once (DCT->DIRE, DIRE->GMP, DCT->GMP)',
bookings: [cont('C-A', 5, YARD.DCT, YARD.DIRE), cont('C-B', 5, YARD.DIRE, YARD.GMP),
bulk('B-FULL', 150, YARD.DCT, YARD.GMP, true)],
stock: stockOf(12, 10) },
{ name: 'S6 — paid bulk vs unpaid containers, scarce NW5',
bookings: [cont('C-UNPAID', 20, YARD.DCT, YARD.GMP), bulk('B-PAID', 300, YARD.DCT, YARD.GMP, true)],
stock: stockOf(20, 10) },
{ name: 'S7 — two paid bulks competing for the same PW2',
bookings: [bulk('B-P1', 200, YARD.DCT, YARD.GMP, true), bulk('B-P2', 200, YARD.DCT, YARD.GMP, true)],
stock: stockOf(5, 10) },
{ name: 'S8 — bulk exactly filling the PW2 (200T)',
bookings: [bulk('B-EXACT', 200, YARD.DCT, YARD.GMP, true)],
stock: stockOf(0, 10) },
{ name: 'S9 — 1T over the PW2 capacity (201T)',
bookings: [bulk('B-OVER', 201, YARD.DCT, YARD.GMP, true)],
stock: stockOf(0, 10) },
{ name: 'S10 — full train: containers both legs + bulk through',
bookings: [cont('C-1', 42, YARD.DCT, YARD.DIRE), cont('C-2', 42, YARD.DIRE, YARD.GMP),
bulk('B-THRU', 200, YARD.DCT, YARD.GMP, true)],
stock: stockOf(42, 10) },
];
for (const c of cases) {
const checks: Check[] = [];
const r = plan(c.bookings, c.stock);
const legs = legsOf(c.bookings);
console.log(`\n── ${c.name}`);
console.log(` plan ${r.plan.length} wagons — ${byType(r.plan)}`);
console.log(` fitting: ${r.fitting.map((b) => b.reference).join(', ') || 'none'}`);
for (const d of r.deferred) console.log(` DEFERRED ${d.reference}: ${d.reason}`);
for (const s of r.plan.filter((x) => x.allocations.length > 1)) {
const spans = s.allocations.map((a) => {
const l = legs.get(a.bookingId)!;
return `${a.bookingReference}[${STOP_NAME[l.from]}->${STOP_NAME[l.to]}]`;
});
console.log(` wagon #${s.sequenceNo} ${s.wagonTypeCode}: ${spans.join(' + ')}`);
}
add(checks, 'per-edge cargo exclusivity holds',
validateWagonCargoExclusivity(r.plan, legs, 2).length === 0,
validateWagonCargoExclusivity(r.plan, legs, 2).join('; ') || 'clean');
add(checks, 'every planned wagon carries at least one allocation',
r.plan.every((s) => s.allocations.length > 0),
`${r.plan.filter((s) => !s.allocations.length).length} empty slot(s)`);
// Per-EDGE cap: a wagon reused on two disjoint legs carries its cap on
// each leg, so assignedWeightTons (the whole-journey sum) may exceed one
// leg's cap legitimately. Check the heaviest single leg instead.
const overCap = r.plan.filter((s) => s.slotLoadType === 'BULK').filter((s) => {
const cap = bulkTonsPerWagon(CARGO, s.wagonTypeId, Number(s.capacityTons));
const perEdge = [0, 0];
for (const a of s.allocations) {
const l = legs.get(a.bookingId)!;
for (let e = l.from; e < l.to; e += 1) perEdge[e] += a.allocatedWeightTons;
}
return Math.max(...perEdge) > cap + 0.001;
});
add(checks, 'no bulk wagon exceeds its cargo cap on any single leg',
overCap.length === 0,
overCap.length ? `${overCap.length} wagon(s) over cap` : 'caps respected per leg');
// Leg-board truthfulness for every shared wagon.
// The leg board no longer relies on the slot's union span: each allocation
// carries its booking's own yards, so the UI splits a shared wagon into one
// bar per corridor. Assert that data IS derivable for every shared wagon.
const unionSpans = r.plan.map((s) => stampSpan(s, c.bookings)).filter((x) => x?.union);
const splittable = r.plan
.filter((s) => s.allocations.length > 1)
.every((s) => {
const corridors = new Set(
s.allocations.map((a) => {
const l = legs.get(a.bookingId);
return l ? `${l.from}-${l.to}` : 'unknown';
}),
);
return !corridors.has('unknown');
});
add(checks, 'leg board can name the leg of every load (per-allocation corridors)',
splittable,
unionSpans.length
? `${unionSpans.length} wagon(s) span a union — UI splits them into ${
[...new Set(r.plan.flatMap((s) => s.allocations.map((a) => {
const l = legs.get(a.bookingId)!;
return `${STOP_NAME[l.from]}->${STOP_NAME[l.to]}`;
})))].join(' | ')} bars`
: 'no shared wagons; spans already exact');
groups.push({ scenario: c.name, checks });
}
console.log('\n\n================ REPORT ================');
let pass = 0, fail = 0;
for (const g of groups) {
console.log(`\n${g.scenario}`);
for (const c of g.checks) {
console.log(` ${c.pass ? 'PASS' : 'FAIL'} ${c.name}\n ${c.detail}`);
c.pass ? (pass += 1) : (fail += 1);
}
}
console.log(`\n----------------------------------------`);
console.log(`TOTAL: ${pass} passed, ${fail} failed`);
await ds.destroy();
}
main().catch((e) => { console.error(e); process.exit(1); });

View File

@@ -0,0 +1,236 @@
/**
* Reproduces the reported S-2026-00045 failure: three DCT->Dire container
* bookings + one Dire->GMP container booking are allocated, but the
* DCT->GMP 695T bulk booking is never selected even though PW2 and NW5
* wagons are free.
*
* Runs the REAL planner against the REAL 42 NW5 + 10 PW2 consist, in several
* booking orders (= different window cycles / arrival orders).
*
* npx ts-node -T scripts/run-s45-scenario.ts
*/
import { DataSource } from 'typeorm';
import {
planWagonsWithStock,
type AllowedWagonTypeMap,
type WagonStock,
} from '../src/modules/train-scheduling/wagon-plan-flex.util';
import { validateWagonCargoExclusivity } from '../src/modules/train-scheduling/utils/wagon-plan.util';
import type { Booking } from '../src/modules/bookings/entities/booking.entity';
import type { WagonType } from '../src/modules/wagon-types/entities/wagon-type.entity';
const YARD = {
DCT: 'fc558b95-da28-4fc3-8348-311a290c34ae',
DIRE: 'f7b1686f-d43e-42aa-bc6a-d3d5849296c9',
GMP: '61ae1e66-c229-4dcd-9851-b2b9424f3a95',
};
const STOPS = [YARD.DCT, YARD.DIRE, YARD.GMP];
const NW5 = '8f717b09-eec1-46ad-be3d-2dc0a56e55e7';
const PW2 = '8eec3a7d-8482-4397-96b6-59a028210722';
const TRAIN_ID = '56eec969-55ec-4bc5-aad6-f4b7b6d17292';
const ds = new DataSource({
type: 'postgres',
host: process.env.DB_HOST || '10.18.7.207',
port: Number(process.env.DB_PORT || 5432),
database: process.env.DB_NAME || 'edr_dev',
username: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'dcba@1234',
});
async function loadConfig(): Promise<{
allowed: AllowedWagonTypeMap;
codes: Map<string, string>;
}> {
const types: WagonType[] = await ds.query(
`SELECT id, code, name, capacity_tons AS "capacityTons",
length_meters AS "lengthMeters", tare_weight_tons AS "tareWeightTons",
supports_container AS "supportsContainer", is_active AS "isActive"
FROM freight.wagon_types WHERE is_active IS NOT FALSE`,
);
const byId = new Map(types.map((t) => [t.id, t]));
const rows = async (sql: string) => {
const r: Array<{ typeId: string; wagonTypeId: string }> = await ds.query(sql);
const map = new Map<string, WagonType[]>();
for (const row of r) {
const wt = byId.get(row.wagonTypeId);
if (wt) map.set(row.typeId, [...(map.get(row.typeId) ?? []), wt]);
}
return map;
};
return {
allowed: {
byCargoTypeId: await rows(
`SELECT cargo_type_id AS "typeId", wagon_type_id AS "wagonTypeId" FROM freight.cargo_type_wagon_types`),
byContainerTypeId: await rows(
`SELECT container_type_id AS "typeId", wagon_type_id AS "wagonTypeId" FROM freight.container_type_wagon_types`),
},
codes: new Map(types.map((t) => [t.id, t.code])),
};
}
async function loadBookings(allowed: AllowedWagonTypeMap): Promise<Map<string, Booking>> {
const rows = await ds.query(
`SELECT b.id, b.reference, b.freight_type AS "freightType",
b.cargo_total_weight_vgm AS "cargoTotalWeightVgm",
b.bulk_total_weight_tons AS "bulkTotalWeightTons",
b.cargo_type_id AS "cargoTypeId", b.origin_yard_id AS "originYardId",
b.destination_yard_id AS "destinationYardId",
b.is_government AS "isGovernment", b.priority_score AS "priorityScore",
b.status, b.payment_status AS "paymentStatus",
b.created_at AS "createdAt"
FROM freight.bookings b
WHERE b.reference LIKE 'SF45-%' AND b.deleted_at IS NULL`);
const cargo = await ds.query(
`SELECT id, cargo_type_name AS "cargoTypeName", code,
unit_of_measure AS "unitOfMeasure",
tons_per_wagon_map AS "tonsPerWagonMap",
items_per_wagon_map AS "itemsPerWagonMap" FROM freight.cargo_types`);
const cargoById = new Map(cargo.map((c: any) => [c.id, c]));
const lines = await ds.query(
`SELECT bc.id, bc.booking_id AS "bookingId", bc.container_type_id AS "containerTypeId",
bc.quantity, bc.vgm_per_unit_tons AS "vgmPerUnitTons",
bc.wagons_required AS "wagonsRequired", ct.code, ct.size_ft AS "sizeFt"
FROM freight.booking_container bc
JOIN freight.container_types ct ON ct.id = bc.container_type_id
WHERE bc.booking_id = ANY($1) AND bc.deleted_at IS NULL`,
[rows.map((r: any) => r.id)]);
return new Map(rows.map((r: any) => {
const ct = r.cargoTypeId ? cargoById.get(r.cargoTypeId) : null;
return [r.reference, {
...r,
cargoType: ct
? { ...ct, wagonTypes: allowed.byCargoTypeId.get(r.cargoTypeId) ?? [] }
: null,
bookingContainers: lines.filter((l: any) => l.bookingId === r.id).map((l: any) => ({
...l,
containerType: { id: l.containerTypeId, code: l.code, sizeFt: l.sizeFt },
units: [],
})),
} as unknown as Booking];
}));
}
/** The train's real consist, as TRAIN-mode stock. */
async function consistStock(codes: Map<string, string>): Promise<WagonStock> {
const rows = await ds.query(
`SELECT wagon_type_id AS "wagonTypeId", count(*)::int AS n
FROM freight.wagons
WHERE train_id = $1 AND deleted_at IS NULL
GROUP BY wagon_type_id`, [TRAIN_ID]);
return {
mode: 'TRAIN',
remainingByTypeId: new Map(rows.map((r: any) => [r.wagonTypeId, r.n])),
codesByTypeId: codes,
};
}
const planByType = (plan: any[]) => {
const c = new Map<string, number>();
for (const s of plan) c.set(s.wagonTypeCode, (c.get(s.wagonTypeCode) ?? 0) + 1);
return [...c.entries()].map(([k, n]) => `${n}x ${k}`).join(', ') || 'none';
};
/** Gross tons riding each corridor edge — the locomotive pull check. */
const perEdgeGross = (plan: any[], legs: Map<string, { from: number; to: number }>) => {
const edges = [0, 0];
for (const slot of plan) {
const spans = slot.allocations.map((a: any) => legs.get(a.bookingId) ?? { from: 0, to: 2 });
const from = Math.min(...spans.map((s: any) => s.from));
const to = Math.max(...spans.map((s: any) => s.to));
for (let e = from; e < to; e += 1) {
edges[e] += Number(slot.tareWeightTons) + Number(slot.assignedWeightTons);
}
}
return edges.map((t) => Math.round(t));
};
async function main() {
await ds.initialize();
const { allowed, codes } = await loadConfig();
const byRef = await loadBookings(allowed);
const stock = await consistStock(codes);
console.log('\n=== S-2026-00045 CONSIST (real) ===');
console.table([...stock.remainingByTypeId.entries()].map(([id, n]) => ({
wagonType: codes.get(id), wagons: n })));
console.log('Route: DCT -> DIRE_DAWA -> GMP (2 edges) Loco pull: 3500T / 1520m\n');
const ORDERS: Array<{ name: string; refs: string[] }> = [
{ name: '1. STAGING ORDER (containers first, bulk last)',
refs: ['SF45-5', 'SF45-3', 'SF45-2', 'SF45-1', 'SF45-4'] },
{ name: '2. BULK FIRST (bulk books earliest window)',
refs: ['SF45-4', 'SF45-1', 'SF45-2', 'SF45-3', 'SF45-5'] },
{ name: '3. BULK IN THE MIDDLE',
refs: ['SF45-1', 'SF45-2', 'SF45-4', 'SF45-3', 'SF45-5'] },
{ name: '4. SEQUENTIAL (as listed by the user)',
refs: ['SF45-1', 'SF45-2', 'SF45-3', 'SF45-4', 'SF45-5'] },
{ name: '5. BULK ONLY (nothing competing)',
refs: ['SF45-4'] },
{ name: '6. BULK + the Dire->GMP leg only',
refs: ['SF45-5', 'SF45-4'] },
];
const summary: Array<Record<string, unknown>> = [];
// Second pass models the staging reality: the bulk booking is PAID (the
// customer's money is already taken) while the containers are not.
const PAID_BULK = process.env.PAID_BULK === '1';
for (const order of ORDERS) {
const bookings = order.refs.map((r) => {
const b = byRef.get(r)!;
if (!b) return b;
return PAID_BULK && r === 'SF45-4'
? ({ ...b, status: 'PAID', paymentStatus: 'PAID' } as Booking)
: b;
}).filter(Boolean);
const legs = new Map(bookings.map((b: any) => [
b.id,
{ from: STOPS.indexOf(b.originYardId), to: STOPS.indexOf(b.destinationYardId) },
]));
const result = planWagonsWithStock({
bookings, allowed,
stock: {
...stock,
remainingByTypeId: new Map(stock.remainingByTypeId),
},
legs, edgeCount: 2, stops: STOPS,
});
const refOf = (id: string) =>
bookings.find((b: any) => b.id === id)?.reference ?? id;
const bulkFitted = result.fitting.some((b) => b.reference === 'SF45-4');
const bulkDeferred = result.deferred.find((d) => d.reference === 'SF45-4');
const gross = perEdgeGross(result.plan, legs);
const excl = validateWagonCargoExclusivity(result.plan, legs, 2);
console.log(`\n──────── ${order.name} ────────`);
console.log(`order: ${order.refs.join(' -> ')}`);
console.log(`plan: ${result.plan.length} wagons — ${planByType(result.plan)}`);
console.log(`gross per edge: DCT->Dire ${gross[0]}T | Dire->GMP ${gross[1]}T (limit 3500T)`);
console.log(`fitting: ${result.fitting.map((b) => b.reference).join(', ') || 'none'}`);
for (const d of result.deferred) console.log(`DEFERRED: ${d.reference}${d.reason}`);
if (excl.length) console.log(`EXCLUSIVITY VIOLATION: ${excl.join('; ')}`);
const bulkSlots = result.plan.filter((s) =>
s.allocations.some((a) => refOf(a.bookingId) === 'SF45-4'));
if (bulkSlots.length) {
console.log(` bulk seated on: ${planByType(bulkSlots)}`);
}
summary.push({
order: order.name.slice(0, 34),
wagons: result.plan.length,
'bulk SF45-4': bulkFitted ? 'SELECTED' : 'NOT SELECTED',
deferred: result.deferred.map((d) => d.reference).join(',') || '-',
reason: bulkDeferred ? bulkDeferred.reason.slice(0, 60) : '-',
});
}
console.log('\n\n================ SUMMARY ================');
console.table(summary);
await ds.destroy();
}
main().catch((e) => { console.error(e); process.exit(1); });

View File

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

View File

@@ -0,0 +1,96 @@
/**
* Seeds the S-2026-00045 reported scenario into edr_dev (idempotent).
* References SF45-* ("Scenario Fortyfive"). Rows are kept as evidence.
*
* SELECT * FROM freight.bookings WHERE reference LIKE 'SF45-%';
*/
const { Client } = require('pg');
const YARD = {
DCT: 'fc558b95-da28-4fc3-8348-311a290c34ae',
DIRE: 'f7b1686f-d43e-42aa-bc6a-d3d5849296c9',
GMP: '61ae1e66-c229-4dcd-9851-b2b9424f3a95', // KALITY = GMP (Gelan)
};
const PERISHABLE = 'a5991d3a-d690-4b7e-98fd-ea3333aa16e7'; // NW5 30T / PW2 20T
const FT40 = '349072e7-8a90-4c03-b682-08976abfd7e8';
const COMPANY = '300d5510-e3a5-4858-bc28-6e3beda8ca80';
const BOOKINGS = [
{ ref: 'SF45-1', type: 'CONTAINER', from: 'DCT', to: 'DIRE', qty: 9,
note: 'S45: DCT->Dire 9x40ft' },
{ ref: 'SF45-2', type: 'CONTAINER', from: 'DCT', to: 'DIRE', qty: 19,
note: 'S45: DCT->Dire 19x40ft' },
{ ref: 'SF45-3', type: 'CONTAINER', from: 'DCT', to: 'DIRE', qty: 19,
note: 'S45: DCT->Dire 19x40ft (second)' },
{ ref: 'SF45-4', type: 'BULK', from: 'DCT', to: 'GMP', tons: 695,
note: 'S45: DCT->GMP 695T Perishable - THE ONE NOT BEING SELECTED' },
{ ref: 'SF45-5', type: 'CONTAINER', from: 'DIRE', to: 'GMP', qty: 42,
note: 'S45: Dire->GMP 42x40ft' },
];
async function main() {
const client = new Client({
host: process.env.DB_HOST || '10.18.7.207',
port: Number(process.env.DB_PORT || 5432),
database: process.env.DB_NAME || 'edr_dev',
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'dcba@1234',
});
await client.connect();
const out = [];
for (const b of BOOKINGS) {
const isBulk = b.type === 'BULK';
const vgmPerUnit = 26;
const totalVgm = isBulk ? b.tons : b.qty * vgmPerUnit;
const existing = await client.query(
`SELECT id FROM freight.bookings WHERE reference = $1`, [b.ref]);
let id;
if (existing.rows.length) {
id = existing.rows[0].id;
await client.query(
`UPDATE freight.bookings
SET cargo_total_weight_vgm=$2, cargo_type_id=$3, origin_yard_id=$4,
destination_yard_id=$5, freight_type=$6, trade_direction='IMPORT',
status='CLEARANCE_READY', payment_status='PENDING',
scheduling_status='ELIGIBLE', train_schedule_id=NULL,
cargo_free_text=$7, updated_at=now()
WHERE id=$1`,
[id, totalVgm, isBulk ? PERISHABLE : null, YARD[b.from], YARD[b.to],
b.type, b.note]);
} else {
const res = await client.query(
`INSERT INTO freight.bookings (
reference, status, total_amount, payment_status, contract_type,
trade_direction, equipment_return, cargo_total_weight_vgm, is_hazardous,
payment_currency, version_number, priority_score, origin_yard_id,
destination_yard_id, cargo_type_id, cargo_free_text, freight_type,
scheduling_status, is_government, customs_clearing_enabled, is_reefer,
booking_type, is_split, company_id, created_at, updated_at
) VALUES ($1,'CLEARANCE_READY',0,'PENDING','NEW','IMPORT','WITHOUT_RETURN',
$2,false,'ETB',1,0,$3,$4,$5,$6,$7,'ELIGIBLE',false,false,false,
'ONE_TIME',false,$8, now(), now())
RETURNING id`,
[b.ref, totalVgm, YARD[b.from], YARD[b.to],
isBulk ? PERISHABLE : null, b.note, b.type, COMPANY]);
id = res.rows[0].id;
}
await client.query(`DELETE FROM freight.booking_container WHERE booking_id=$1`, [id]);
if (!isBulk) {
await client.query(
`INSERT INTO freight.booking_container
(booking_id, container_type_id, quantity, vgm_per_unit_tons,
total_vgm_tons, wagons_required, container_size, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,'40ft', now(), now())`,
[id, FT40, b.qty, vgmPerUnit, b.qty * vgmPerUnit, b.qty]);
}
out.push({ ref: b.ref, id, note: b.note });
}
console.table(out);
await client.end();
}
main().catch((e) => { console.error(e.message); process.exit(1); });

View File

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

View File

@@ -75,7 +75,7 @@ export const TrainSchedulingView = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.view);
// 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);

View File

@@ -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");
},
);
});
});

View File

@@ -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<string, string>;
/**
* 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<string, string>;
/** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */
buyerWeredaCodes: Record<string, string>;
/**
* 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<string, string>;
/**
* 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),

View File

@@ -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<string, string> => {
const map: Record<string, string> = {};
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<string, string> = 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<string, string> = buildMap((r) => [r[1], r[4]]);
export const ETHIOPIA_WOREDA_CODES: Record<string, string> = 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];
}

View File

@@ -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<string, string>, 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/,
);
});
});

View File

@@ -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<Level, { name: 1 | 3 | 5 | 7; no: 0 | 2 | 4 | 6; column: string }> = {
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<string, string>();
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<Level, "country">;
/** 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 <workbook.xlsx>`.",
);
}
// `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;
}
}

View File

@@ -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 <path-to.xlsx>
*
* 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[] = [];

View File

@@ -67,6 +67,7 @@ const TRIGGER_ROUTE_LABELS: Partial<Record<Rate['trigger'], string>> = {
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',
};

View File

@@ -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<void> {
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<void> {
// 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"
`);
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "freight"."additional_charge"`);
}
}

View File

@@ -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<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS planned_wagon_yards jsonb
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS planned_wagon_yards
`);
}
}

View File

@@ -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<void> {
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<void> {
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"
`);
}
}

View File

@@ -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<void> {
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<void> {
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`,
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -47,6 +47,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"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"],

View File

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

View File

@@ -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.

View File

@@ -60,11 +60,7 @@ const context = (over: Partial<EimsMapperContext> = {}): 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", () => {

View File

@@ -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<string, string>;
/**
* 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<string, string>;
/**
* 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<string, string>;
/**
* 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<string, string>;
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<string, string>,
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<string, string>,
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, string>,
): 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,

View File

@@ -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_<slug>_<n>`) — 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_<timestamp>_<n>` 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();
});
});

View File

@@ -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<AdditionalCharge> {
constructor(@InjectRepository(AdditionalCharge) repository: Repository<AdditionalCharge>) {
super(repository);
}
}

View File

@@ -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<AdditionalCharge> {
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<Freight.AdditionalCharge[]> {
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<Freight.AdditionalCharge[]> {
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<Freight.AdditionalCharge[]> {
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<AdditionalCharge> {
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<void> {
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<Freight.AdditionalCharge[]> {
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<void> {
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<Freight.AdditionalCharge[]> {
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;
}
}
}

View File

@@ -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<ClearanceChargeType, string> = {
MISCELLANEOUS: 'Miscellaneous charges',
};
/** Statuses the customer sees — drafts (DOC_UPLOADED / BILLED) stay GL-internal. */
export const CUSTOMER_VISIBLE_CHARGE_STATUSES: ReadonlySet<ClearanceChargeStatus> =
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<Freight.ClearanceCharge[]> {
return (await this.list(bookingId)).filter((c) =>
CUSTOMER_VISIBLE_CHARGE_STATUSES.has(c.status),
);
}
private async findCharge(
bookingId: string,
chargeId: string,
): Promise<BookingClearanceCharge> {
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<Freight.ClearanceCharge[]> {
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<Freight.ClearanceCharge[]> {
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<Freight.ClearanceCharge[]> {
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<Freight.ClearanceCharge[]> {
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<Freight.ClearanceCharge[]> {
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,
},
});

View File

@@ -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']);
});
});

View File

@@ -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.`;

View File

@@ -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<Freight.BookingPayableSummary[]> {
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<string, Freight.BookingPayableSummary>();
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()];
}
}

View File

@@ -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<string, unknown> = {}) =>
@@ -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 = (

View File

@@ -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<GeneratePriceResponseDto> {
@@ -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,

View File

@@ -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.

View File

@@ -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<void> {
// 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<Booking> {
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<Booking> => {
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<void> {
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_<label>_<n>`); it is what GL sees in the review grid instead
// of a raw filename like "scan_003.pdf".
title: adHocLabel(file.fieldname),
});
// Ad-hoc docs (custom_*) are not part of the required gate; still tracked.
const settingCode = file.fieldname.startsWith("custom_")

View File

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

View File

@@ -38,7 +38,11 @@ import { BookingsService } from './bookings.service';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import { BookingDocumentReview } from './entities/booking-document-review.entity';
import { BookingClearanceCharge } from './entities/booking-clearance-charge.entity';
import { AdditionalCharge } from './entities/additional-charge.entity';
import { AdditionalChargeRepository } from './additional-charge.repository';
import { AdditionalChargeService } from './additional-charge.service';
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
import { BookingPayablesService } from './booking-payables.service';
import { BookingClearanceEvent } from './entities/booking-clearance-event.entity';
import { ClearanceEventService } from './clearance-event.service';
import { BookingContainer } from './entities/booking-container.entity';
@@ -82,6 +86,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
ConsolidationApproval,
BookingClearanceCharge,
BookingClearanceEvent,
AdditionalCharge,
]),
BillingModule,
DocumentsModule,
@@ -118,7 +123,10 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingContractService,
BookingInvoiceService,
BookingClearanceChargeService,
BookingPayablesService,
ClearanceEventService,
AdditionalChargeRepository,
AdditionalChargeService,
ContractTemplateResolver,
ContractViewModelBuilder,
ContractPricingScheduleBuilder,

View File

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

View File

@@ -52,6 +52,7 @@ import {
FreightType,
} from './entities/booking.entity';
import { Booking } from './entities/booking.entity';
import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity';
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
import { FileRecord } from '../files/entities/file.entity';
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
@@ -427,7 +428,8 @@ export class BookingsService {
'containerNumber', ci.container_number,
'sealNumber', ci.seal_number,
'positionOnWagon', ci.position_on_wagon,
'grossWeightTons', ci.gross_weight_tons
'grossWeightTons', ci.gross_weight_tons,
'sizeFt', cit.size_ft
) ORDER BY ci.position_on_wagon, ci.container_number
) FILTER (WHERE ci.id IS NOT NULL),
'[]'
@@ -443,6 +445,7 @@ export class BookingsService {
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
LEFT JOIN freight.wagon_allocation_container_items ci
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
LEFT JOIN freight.container_types cit ON cit.id = ci.container_type_id
LEFT JOIN freight.wagon_allocation_bulk_loads bl
ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
@@ -2260,16 +2263,25 @@ export class BookingsService {
return this.findById(booking.id);
}
/** Upload documents for a DRAFT booking. */
/**
* Upload documents for a DRAFT booking — or for a booking created by
* rebooking a wagon-cancellation credit, whose paperwork may have changed
* with the new containers (old documents stay; new ones ride alongside).
*/
async uploadDocuments(
id: string,
files: Express.Multer.File[],
): Promise<Booking> {
const booking = await this.findById(id);
if (booking.status !== 'DRAFT') {
throw new BadRequestException(
'Documents can only be uploaded for DRAFT bookings',
);
const rebooked = await this.dataSource
.getRepository(BookingWagonCancellation)
.findOne({ where: { rebookedBookingId: id } });
if (!rebooked) {
throw new BadRequestException(
'Documents can only be uploaded for DRAFT bookings',
);
}
}
await this.filesService.uploadMany(id, 'bookings', files);
return this.findById(id);

View File

@@ -146,3 +146,20 @@ export function clearanceDocumentsOpen(booking: Booking): boolean {
if (booking.paymentStatus === 'PAID') return false;
return true;
}
/**
* The label the customer typed for an ad-hoc clearance document, recovered from
* its file code. The portal encodes it as `custom_<slug>_<n>`; a plain
* `custom_<n>` (older uploads, or an unnamed row) yields null so callers fall
* back to the filename.
*/
export function adHocLabel(fileKey: string): string | null {
const m = /^custom_(.+)_\d+$/.exec(fileKey);
if (!m) return null;
// Legacy keys are `custom_<timestamp>_<n>`, which this regex reads as a label
// of digits. Those carry no name — reject them so the caller falls back to
// the filename instead of showing "1755780000000".
if (/^\d+$/.test(m[1])) return null;
const label = m[1].replace(/-/g, ' ').trim();
return label ? label.charAt(0).toUpperCase() + label.slice(1) : null;
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,49 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsDateString,
IsIn,
IsNumber,
IsOptional,
IsPositive,
IsString,
Length,
} from 'class-validator';
export class CreateAdditionalChargeDto {
@ApiProperty({ example: 'Re-weighing fee at Mojo dry port' })
@IsString()
@Length(1, 2000)
reason!: string;
@ApiProperty({ example: 4500 })
@Type(() => Number)
@IsNumber()
@IsPositive()
amount!: number;
@ApiProperty({ example: 'ETB' })
@IsString()
@Length(3, 8)
currency!: string;
/** 'send' issues the invoice + notifies the customer immediately; omit/'draft' just saves it. */
@ApiPropertyOptional({ enum: ['draft', 'send'], default: 'draft' })
@IsOptional()
@IsIn(['draft', 'send'])
action?: 'draft' | 'send';
/** Payment due date; omit to fall back to the invoice's own default term (14 days) on send. */
@ApiPropertyOptional({ example: '2026-09-01' })
@IsOptional()
@IsDateString()
dueDate?: string;
}
export class CancelAdditionalChargeDto {
@ApiPropertyOptional({ example: 'Raised in error' })
@IsOptional()
@IsString()
@Length(1, 2000)
reason?: string;
}

View File

@@ -1,6 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsNumber, IsPositive, IsString, Length } from 'class-validator';
import {
IsNumber,
IsOptional,
IsPositive,
IsString,
Length,
MaxLength,
} from 'class-validator';
export class BillClearanceChargeDto {
@ApiProperty({ example: 12500.5 })
@@ -13,4 +20,18 @@ export class BillClearanceChargeDto {
@IsString()
@Length(3, 8)
currency!: string;
/** What the price is for. Required for miscellaneous charges (checked in the service). */
@ApiPropertyOptional({ example: 'Container cleaning and weighbridge fee' })
@IsOptional()
@IsString()
@MaxLength(1000)
description?: string;
}
export class RejectClearanceChargeDto {
@ApiProperty({ example: 'The weighbridge fee was already paid at the port.' })
@IsString()
@Length(1, 1000)
note!: string;
}

View File

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

View File

@@ -0,0 +1,78 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from './booking.entity';
export const ADDITIONAL_CHARGE_STATUSES = [
'DRAFT',
'SENT',
'PAID',
'CANCELLED',
] as const;
export type AdditionalChargeStatus = (typeof ADDITIONAL_CHARGE_STATUSES)[number];
/**
* An ad-hoc extra charge finance raises against a booking — free-text reason,
* any number per booking (unlike `BookingClearanceCharge`, which caps at one
* per type). DRAFT until finance sends it; sending issues the payable invoice
* and notifies the customer (in-app + SMS + email). PAID via the standard
* `additional_charge.invoice.paid` settlement event.
*/
@Entity({ schema: 'freight', name: 'additional_charge' })
@Index(['bookingId'])
export class AdditionalCharge extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'reason', type: 'text' })
reason!: string;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
status!: AdditionalChargeStatus;
@Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2 })
amount!: string;
@Column({ name: 'currency', type: 'varchar', length: 8 })
currency!: string;
/** Optional payment due date; unset falls back to the invoice's own default term on send. */
@Column({ name: 'due_at', type: 'timestamptz', nullable: true })
dueAt?: Date | null;
/** The supporting attachment (FileRecord), if any. */
@Column({ name: 'file_record_id', type: 'uuid', nullable: true })
fileRecordId?: string | null;
/** The payable invoice issued for this charge (null until SENT). */
@Column({ name: 'invoice_id', type: 'uuid', nullable: true })
invoiceId?: string | null;
/** CBE bill reference / PNR the customer pays against, once issued. */
@Column({ name: 'payment_reference', type: 'varchar', length: 64, nullable: true })
paymentReference?: string | null;
@Column({ name: 'created_by_staff_id', type: 'uuid', nullable: true })
createdByStaffId?: string | null;
@Column({ name: 'sent_by_staff_id', type: 'uuid', nullable: true })
sentByStaffId?: string | null;
@Column({ name: 'sent_at', type: 'timestamptz', nullable: true })
sentAt?: Date | null;
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
paidAt?: Date | null;
@Column({ name: 'cancelled_by_staff_id', type: 'uuid', nullable: true })
cancelledByStaffId?: string | null;
@Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true })
cancelledAt?: Date | null;
@Column({ name: 'cancel_reason', type: 'text', nullable: true })
cancelReason?: string | null;
}

View File

@@ -9,20 +9,24 @@ export const CLEARANCE_CHARGE_STATUSES = [
'DOC_UPLOADED',
'BILLED',
'SENT',
'REJECTED',
'ACCEPTED',
'PAID',
] as const;
export type ClearanceChargeStatus = (typeof CLEARANCE_CHARGE_STATUSES)[number];
/**
* Post-finalization clearance charge billed to the customer — at most one
* PORT_CHARGES and one MISCELLANEOUS row per booking. GL Djibouti uploads the
* port-charges document (DOC_UPLOADED); GL Ethiopia sets amount + currency
* (BILLED) and issues the invoice (SENT); the billing `clearance_charge.invoice.paid`
* event marks it PAID. MISCELLANEOUS is created whole by GL Ethiopia and only
* after the port charge is paid.
* Clearance charge billed to the customer. One PORT_CHARGES row per booking
* (enforced by a partial unique index) and any number of MISCELLANEOUS rows.
* GL Djibouti uploads the port-charges document (DOC_UPLOADED); GL Ethiopia
* sets amount + currency + description (BILLED) and proposes it to the
* customer (SENT). The customer either REJECTS with a note (GL revises and
* re-sends) or ACCEPTS, which issues the invoice and locks the charge; the
* billing `clearance_charge.invoice.paid` event marks it PAID. The two levels
* are independent — either may be raised first.
*/
@Entity({ schema: 'freight', name: 'booking_clearance_charge' })
@Index(['bookingId', 'type'], { unique: true })
@Index(['bookingId'])
export class BookingClearanceCharge extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@@ -47,6 +51,20 @@ export class BookingClearanceCharge extends BaseEntity {
@Column({ name: 'currency', type: 'varchar', length: 8, nullable: true })
currency?: string | null;
/** What the price is for, written by GL. */
@Column({ name: 'description', type: 'text', nullable: true })
description?: string | null;
/** Customer's reason when REJECTED; cleared when GL revises. */
@Column({ name: 'customer_note', type: 'text', nullable: true })
customerNote?: string | null;
@Column({ name: 'customer_decided_at', type: 'timestamptz', nullable: true })
customerDecidedAt?: Date | null;
@Column({ name: 'customer_decided_by', type: 'uuid', nullable: true })
customerDecidedBy?: string | null;
/** The payable invoice issued for this charge (null until SENT). */
@Column({ name: 'invoice_id', type: 'uuid', nullable: true })
invoiceId?: string | null;

View File

@@ -11,6 +11,12 @@ export const REVIEW_NOTE_TYPES = [
* (price/files). One row per round — the draft/change-request loop can repeat.
*/
'DRAFT_DECL_CHANGE_REQUEST',
/**
* GL asked the customer for additional clearance document(s). Shown as a
* thread on both the GL clearance page and the customer's portal — the
* customer answers by uploading an ad-hoc document.
*/
'ADDITIONAL_DOC_REQUEST',
] as const;
export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];

View File

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

View File

@@ -23,7 +23,7 @@ import {
type RiskAssignmentRecord,
} from './entities/clearance-milestone.entity';
import { Booking } from '../bookings/entities/booking.entity';
import { clearanceCodesForBooking } from '../bookings/clearance.util';
import { adHocLabel, clearanceCodesForBooking } from '../bookings/clearance.util';
import { assertDoCollectionDates } from './contract-clearance.util';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
@@ -66,6 +66,12 @@ export interface BookingClearanceView {
}>;
allApproved: boolean;
documentsOpen: boolean;
docRequests: Array<{
id: string;
note: string;
byName: string | null;
at: string;
}>;
phase?: string | null;
milestones?: Array<{
id: string;
@@ -206,9 +212,14 @@ export class BookingClearanceService {
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: BookingClearanceView['documents'] = [];
@@ -257,7 +268,9 @@ export class BookingClearanceService {
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',
@@ -334,7 +347,8 @@ export class BookingClearanceService {
} catch {
train = null;
}
const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId);
// Removed from the clearance flow — see gl-operations.service.
const finalInvoice: ClearanceFinalInvoiceSummary | null = null;
const bookingMilestone = (code: string) =>
milestones.find((m) => m.milestoneCode === code);
const gatepass = await this.glOperationsService.gatepassForBooking(bookingId);
@@ -357,6 +371,12 @@ export class BookingClearanceService {
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(),
})),
phase,
milestones: milestones.map((m) => ({
id: m.id,
@@ -765,6 +785,50 @@ export class BookingClearanceService {
return updated;
}
/**
* GL Ethiopia skips the draft-declaration round entirely: the customer is
* not sent an estimate, staff file the real customs declaration directly.
* Duty & tax passes with it by default — there is no draft price to advise
* from. Advising duty later still works and overrides the skip (a skipped
* milestone is completed normally by adviseDuty).
*/
async skipDraftDeclaration(bookingId: string, userId?: string): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Draft declaration applies only to import bookings.');
}
await this.milestoneService.ensureBookingMilestones(bookingId, 'IMPORT');
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
const uploaded = milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED');
if (uploaded?.status === 'COMPLETED') {
throw new BadRequestException(
'A draft declaration was already sent to the customer — it can no longer be skipped.',
);
}
await this.workflowService.assertPriorCompleteForBooking(
bookingId,
'IMPORT',
'DRAFT_DECLARATION_UPLOADED',
);
await this.workflowService.skipMilestonesForBooking(bookingId, [
'DRAFT_DECLARATION_UPLOADED',
'DRAFT_DECLARATION_ACCEPTED',
]);
await this.workflowService.onDutySkippedForBooking(bookingId);
await this.bookingsRepository.update(bookingId, {
dutyRequired: false,
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'DRAFT_DECLARATION_SKIPPED',
label:
'Skipped the draft declaration — filing the customs declaration directly (duty & tax passed by default)',
actorId: userId ?? null,
});
return this.bookingsService.findById(bookingId);
}
/**
* The customer accepts the draft declaration — GL Ethiopia may now file the
* real customs declaration.

View File

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

View File

@@ -330,7 +330,9 @@ export class ContractClearanceService {
let train: ClearanceTrainState | null = null;
let bookingMilestones: ClearanceMilestone[] = [];
let finalInvoice: ClearanceFinalInvoiceSummary | null = null;
// Removed from the clearance flow — see gl-operations.service. Kept in the
// payload (always null) so existing consumers keep type-checking.
const finalInvoice: ClearanceFinalInvoiceSummary | null = null;
if (cycle?.bookingId) {
try {
train = await this.glOperationsService.trainState(cycle.bookingId);
@@ -340,7 +342,6 @@ export class ContractClearanceService {
bookingMilestones = await this.workflowService.listMilestonesForBooking(
cycle.bookingId,
);
finalInvoice = await this.glOperationsService.finalInvoiceSummary(cycle.bookingId);
}
const bookingMilestone = (code: string) =>
bookingMilestones.find((m) => m.milestoneCode === code);

View File

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

View File

@@ -376,12 +376,21 @@ export class ContractPricingService {
// own container-type rate), bulk contracts freeze the route's bulk fee.
// A customs contract may not proceed without the fee(s) configured.
if (contract.customsClearingEnabled) {
// An Ethiopian-side-only customs service prices off its own rate; the
// snapshot codes carry the same prefix so booking pricing finds them.
const customsType = contract.serviceType?.includesEthiopianCustomsOnly
? 'ETHIOPIAN_CUSTOMS_CLEARANCE'
: 'CUSTOMS_CLEARANCE';
const customsLabel =
customsType === 'ETHIOPIAN_CUSTOMS_CLEARANCE'
? 'Ethiopian customs clearance service'
: 'Customs clearance service';
// Strict, no route-less fallback.
// ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots.
const onLeg = route
? liveRates.filter(
(r) =>
r.rateType === 'CUSTOMS_CLEARANCE' &&
r.rateType === customsType &&
r.currency === 'USD' &&
r.tradeDirection === contract.tradeDirection &&
r.originYardId === route.originYardId &&
@@ -406,14 +415,14 @@ export class ContractPricingService {
);
if (!rate || Number(rate.rateValue) <= 0) {
throw new UnprocessableEntityException(
`No customs clearance service fee is configured for ${size} containers on this direction and route. Ask the rates team to set a live CUSTOMS_CLEARANCE rate for this container type and origin → destination.`,
`No customs clearance service fee is configured for ${size} containers on this direction and route. Ask the rates team to set a live ${customsType} rate for this container type and origin → destination.`,
);
}
lineItems.push({
// Distinct code per size so the frozen snapshots don't collide —
// booking pricing looks each size up by CUSTOMS_CLEARANCE_<FT>FT.
code: `CUSTOMS_CLEARANCE_${sizeFt}FT`,
label: `Customs clearance service (${size})`,
// booking pricing looks each size up by <customsType>_<FT>FT.
code: `${customsType}_${sizeFt}FT`,
label: `${customsLabel} (${size})`,
unit: toContractUnit(rate.rateUnit),
unitPrice: convert(Number(rate.rateValue)),
containerSize: size,
@@ -432,12 +441,12 @@ export class ContractPricingService {
: undefined) ?? onLeg.find((r) => !r.containerTypeId && !r.cargoTypeId);
if (!rate || Number(rate.rateValue) <= 0) {
throw new UnprocessableEntityException(
'No bulk customs clearance service fee is configured for this cargo type on this direction and route. Ask the rates team to set a live bulk CUSTOMS_CLEARANCE rate for this commodity and origin → destination.',
`No bulk customs clearance service fee is configured for this cargo type on this direction and route. Ask the rates team to set a live bulk ${customsType} rate for this commodity and origin → destination.`,
);
}
lineItems.push({
code: 'CUSTOMS_CLEARANCE',
label: `Customs clearance service (${scope?.cargoType?.cargoTypeName ?? 'bulk'})`,
code: customsType,
label: `${customsLabel} (${scope?.cargoType?.cargoTypeName ?? 'bulk'})`,
unit: toContractUnit(rate.rateUnit),
unitPrice: convert(Number(rate.rateValue)),
cargoTypeCode: scope?.cargoType?.code ?? null,

View File

@@ -124,11 +124,14 @@ export class ContractsService {
return `CTR-${year}-${String(seq + 1).padStart(5, '0')}`;
}
/** The service type a contract is sold under (null when the id is unknown). */
private resolveServiceType(serviceTypeId: string): Promise<ServiceType | null> {
return this.dataSource.getRepository(ServiceType).findOne({ where: { id: serviceTypeId } });
}
/** Whether a service type bundles customs clearance. */
private async resolveIncludesCustoms(serviceTypeId: string): Promise<boolean> {
const serviceType = await this.dataSource
.getRepository(ServiceType)
.findOne({ where: { id: serviceTypeId } });
const serviceType = await this.resolveServiceType(serviceTypeId);
return serviceType?.includesCustoms ?? false;
}
@@ -324,7 +327,8 @@ export class ContractsService {
}
// Customs clearing is owned by the service type, not the customer.
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
const serviceType = await this.resolveServiceType(dto.serviceTypeId);
const includesCustoms = serviceType?.includesCustoms ?? false;
// Intercity never crosses a border, so a customs-including service type is
// a contradiction — the wizard hides them, the API enforces it.
if (dto.tradeDirection === 'DOMESTIC' && includesCustoms) {
@@ -344,6 +348,8 @@ export class ContractsService {
freightType: dto.freightType,
paymentCurrency: 'USD',
customsClearingEnabled: includesCustoms,
// Decides which customs fee the probe looks up (Ethiopian-only vs full).
serviceType,
isHazardous: dto.isHazardous ?? false,
isReefer: dto.isReefer ?? false,
equipmentReturn: dto.equipmentReturn ?? null,

View File

@@ -779,7 +779,15 @@ export class GlOperationsService {
};
}
/** Final-invoice state joined with its document + slip files, for clearance views. */
/**
* Final-invoice state joined with its document + slip files.
*
* RETIRED from the clearance flow: the post-offload GL Djibouti invoice is no
* longer part of the export process, is not rendered on either desk or the
* portal, and never gated anything downstream. The endpoints and this reader
* stay so already-issued invoices remain resolvable; nothing calls it from a
* clearance view any more.
*/
async finalInvoiceSummary(
bookingId: string,
): Promise<Freight.ClearanceFinalInvoiceSummary | null> {

View File

@@ -36,9 +36,12 @@ const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
tin: "0999930000",
vatNumber: "123475885858",
phone: "0912345678",
region: "13",
zone: "SHA",
woreda: "574",
// A real MoR address — Ethiopia / SOMALI / FAAFAN ZONE / JIJIGA, which the Ministry master
// codes as 70 / 6 / 31 / 190. Spelled the way EDR and e-Trade actually store it, so the
// alias layer is exercised end to end rather than only in the resolver's own spec.
region: "Somali",
zone: "Fafen",
woreda: "Jigjiga",
kebele: "03",
houseNo: "NEW",
country: "Ethiopia",

View File

@@ -7,6 +7,7 @@ import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialE
import { EimsConfig } from "../../config/eims.config";
import { Invoice } from "../billing/entities/invoice.entity";
import { MorGeoCodes, resolveMorGeo } from "../../config/mor-location.resolver";
import { EimsDocumentType, EimsMapperLine, toEimsInvoice } from "../billing/eims-invoice.mapper";
import { sendCompanyChannels } from "../notifications/notify-company.util";
import { NotificationsService } from "../notifications/notifications.service";
@@ -32,6 +33,8 @@ interface BulkReservation {
invoice: Invoice & { lines: EimsMapperLine[] };
documentType: EimsDocumentType;
relatedDocument: string | null;
/** Resolved before this reservation existed — see the `prepared` pass in `bulkRegister`. */
buyerGeo: MorGeoCodes;
invoiceCounter: number;
documentNumber: string;
previousIrn: string;
@@ -123,7 +126,16 @@ export class EimsBulkRegistrationService {
}
relatedDocument = invoice.relatedInvoice.eimsIrn;
}
return { invoice, documentType, relatedDocument };
// Same rule as the single-invoice path: buyer geography is resolved from the MoR location
// master before reserveBulk touches a counter, so one bad company address fails the whole
// batch locally instead of burning a block of EIMS sequence numbers.
const buyerGeo = resolveMorGeo({
country: invoice.company?.country,
region: invoice.company?.region,
zone: invoice.company?.zone,
woreda: invoice.company?.woreda,
});
return { invoice, documentType, relatedDocument, buyerGeo };
});
if (prepared.length === 0) {
@@ -141,6 +153,7 @@ export class EimsBulkRegistrationService {
r.invoice,
this.sellerCache.getSellerDetails(cfg),
buildEimsContext(cfg, {
buyerGeo: r.buyerGeo,
documentNumber: r.documentNumber,
invoiceCounter: r.invoiceCounter,
previousIrn: r.previousIrn,
@@ -269,7 +282,12 @@ export class EimsBulkRegistrationService {
/** TX1. Reserve a contiguous block of N counters, one per invoice, in the given order. */
private async reserveBulk(
prepared: Array<{ invoice: Invoice & { lines: EimsMapperLine[] }; documentType: EimsDocumentType; relatedDocument: string | null }>,
prepared: Array<{
invoice: Invoice & { lines: EimsMapperLine[] };
documentType: EimsDocumentType;
relatedDocument: string | null;
buyerGeo: MorGeoCodes;
}>,
systemNumber: string,
placeholder: string,
): Promise<BulkReservation[]> {
@@ -302,7 +320,7 @@ export class EimsBulkRegistrationService {
// Locked in the caller's given order — stable, avoids two concurrent bulk calls deadlocking
// on the opposite lock order.
for (const { invoice, documentType, relatedDocument } of prepared) {
for (const { invoice, documentType, relatedDocument, buyerGeo } of prepared) {
const locked = await this.lockInvoice(manager, invoice.id);
const thisCounter = counter++;
const thisDocNumber = String(docNumber++);
@@ -322,6 +340,7 @@ export class EimsBulkRegistrationService {
invoice: Object.assign(locked, { lines: invoice.lines }),
documentType,
relatedDocument,
buyerGeo,
invoiceCounter: thisCounter,
documentNumber: thisDocNumber,
previousIrn: thisPreviousIrn,

View File

@@ -66,7 +66,13 @@ describe("assertEimsInvoiceConfig — charge-type overrides", () => {
});
describe("buildEimsContext — taxForLine", () => {
const input = { documentNumber: "24", invoiceCounter: 7, previousIrn: "", session: SESSION };
const input = {
buyerGeo: { Country: "70", Region: "6", City: "31", Wereda: "190" },
documentNumber: "24",
invoiceCounter: 7,
previousIrn: "",
session: SESSION,
};
const line = (chargeType: string) => ({ chargeType, quantity: 1, unitRate: 100, amount: 100 });
it("uses the per-chargeType override when one is configured", () => {

View File

@@ -1,5 +1,6 @@
import { BadRequestException } from "@nestjs/common";
import { EimsConfig } from "../../config/eims.config";
import { MorGeoCodes } from "../../config/mor-location.resolver";
import { EimsSessionContext } from "./eims-auth.service";
import {
EimsMapperContext,
@@ -149,6 +150,12 @@ export function buildEimsSeller(config: EimsConfig): EimsSellerDetails {
}
export interface EimsContextInput {
/**
* The buyer's MoR location codes, resolved from the Ministry location master by
* `resolveMorGeo` **before** the caller reserved an EIMS counter — see
* `EimsMapperContext.buyerGeo`.
*/
buyerGeo: MorGeoCodes;
/** `DocumentDetails.DocumentNumber`. The caller decides its source. */
documentNumber: string;
invoiceCounter: number;
@@ -205,11 +212,7 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
unitDefault: invoice.unitDefault,
incomeWithholdValue: invoice.incomeWithholdValue!,
transactionWithholdValue: invoice.transactionWithholdValue!,
buyerCountryCode: invoice.buyerCountryCode,
buyerCountryCodes: invoice.buyerCountryCodes,
buyerRegionCodes: invoice.buyerRegionCodes,
buyerWeredaCodes: invoice.buyerWeredaCodes,
buyerCityCodes: invoice.buyerCityCodes,
buyerGeo: input.buyerGeo,
// TEMPORARY — see EimsInvoiceConfig.buyerIdType.
buyerIdType: invoice.buyerIdType,
buyerIdNumber: invoice.buyerIdNumber,

View File

@@ -13,6 +13,7 @@ import { EimsClientService } from "./eims-client.service";
import { EimsApiException, EimsConfigException } from "./eims.errors";
import { buildEimsSeller } from "./eims-invoice-context";
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
import { ETradeService } from "../companies/services/etrade.service";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
import { EimsSystemState } from "./entities/eims-system-state.entity";
import { EimsInvoiceStatus } from "./eims-registration.types";
@@ -58,9 +59,12 @@ const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
vatNumber: "123475885858",
phone: "0912345678",
email: "buyer@abc.et",
region: "13",
zone: "SHA",
woreda: "574",
// A real MoR address — Ethiopia / SOMALI / FAAFAN ZONE / JIJIGA, which the Ministry master
// codes as 70 / 6 / 31 / 190. Spelled the way EDR and e-Trade actually store it, so the
// alias layer is exercised end to end rather than only in the resolver's own spec.
region: "Somali",
zone: "Fafen",
woreda: "Jigjiga",
kebele: "03",
houseNo: "NEW",
country: "Ethiopia",
@@ -502,21 +506,23 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
});
});
it("a mapper failure after reservation (e.g. unmapped buyer country) also releases the reservation", async () => {
it("a mapper failure after reservation still releases the reservation", async () => {
// Regression: toEimsInvoice/buildEimsContext used to sit outside the try/catch that calls
// settleFailure — a throw here left the reservation permanently orphaned (a real live incident:
// 500 on register, then every subsequent attempt 409'd "already in flight" until manually
// resolved). This never reaches postSigned at all — the mapper throws before submit() is called.
const db = new FakeDb([
invoiceRow({ company: { ...invoiceRow().company, country: "France" } as never }),
]);
//
// The trigger used to be an unmapped buyer country. That can no longer get this far: geography
// is resolved before the reservation now (see the test below). A line/total mismatch is a
// mapper-only failure that still reaches this point.
const db = new FakeDb([invoiceRow({ totalAmount: 999999 })]);
const postSigned = jest.fn();
// The mapper throws a plain Error (it's a pure function, not a NestJS layer) — that's the
// point: settleFailure must treat *any* non-EimsApiException as pre-wire, not just its own
// known exception types.
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow(
/no MoR country code mapping/,
/lines sum to/,
);
expect(postSigned).not.toHaveBeenCalled();
@@ -532,6 +538,69 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
});
});
it("an unmappable buyer address fails before a counter is ever reserved", async () => {
// The whole point of resolving geography ahead of reserve(): a company-record problem is a
// local data problem, and it must not cost an EIMS sequence number. Nothing about the invoice
// or the system state may change.
const db = new FakeDb([
invoiceRow({ company: { ...invoiceRow().company, woreda: "Nowhere" } as never }),
]);
const before = { ...db.state };
const postSigned = jest.fn();
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow(
/no MoR LOCALITY_DESC match/,
);
expect(postSigned).not.toHaveBeenCalled();
expect(db.state).toMatchObject({
nextInvoiceCounter: before.nextInvoiceCounter,
nextDocumentNumber: before.nextDocumentNumber,
inFlightInvoiceId: null,
});
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
eimsStatus: EimsInvoiceStatus.NotSubmitted,
eimsInvoiceCounter: null,
});
});
it("files the buyer's MoR codes, resolved from the location master with no e-Trade call", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "IRN-1" } });
// The real seller cache, wired to an e-Trade mock that must never be reached: registration
// reads the company row EDR already stored, so filing stays deterministic and independent of
// e-Trade's availability. `refresh()` is deliberately not called — the cache stays empty and
// the seller falls back to static config, exactly as it does on a cold process.
const cfg = config();
const resolveCompanyData = jest.fn();
const sellerCache = new EimsSellerCacheService(
{ resolveCompanyData, extractRegistrationData: jest.fn() } as unknown as ETradeService,
{ get: () => cfg } as unknown as ConfigService,
);
const service = new EimsInvoiceRegistrationService(
db.asDataSource(),
{ get: () => cfg } as unknown as ConfigService,
{ postSigned, postBearer: jest.fn() } as unknown as EimsClientService,
{ getSessionContext: jest.fn().mockResolvedValue(SESSION) } as unknown as EimsAuthService,
{ notify: jest.fn().mockResolvedValue(undefined) } as unknown as NotificationInboxService,
{ directSend: jest.fn().mockResolvedValue(undefined) } as unknown as NotificationsService,
sellerCache,
);
await service.registerInvoiceWithEims(INVOICE_ID);
const [, body] = postSigned.mock.calls[0];
expect(body.BuyerDetails).toMatchObject({
Country: "70",
Region: "6",
City: "31",
Wereda: "190",
});
expect(resolveCompanyData).not.toHaveBeenCalled();
});
it("treats a success response with no IRN as a failed registration", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "" } });

View File

@@ -29,6 +29,7 @@ import { EimsClientService } from "./eims-client.service";
import { EimsApiException, EimsConfigException } from "./eims.errors";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
import { EimsSystemState } from "./entities/eims-system-state.entity";
import { resolveMorGeo } from "../../config/mor-location.resolver";
import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context";
import {
EimsInvoiceError,
@@ -116,6 +117,17 @@ export class EimsInvoiceRegistrationService {
relatedDocument = invoice.relatedInvoice.eimsIrn;
}
// Buyer geography is resolved from the MoR location master *here*, ahead of the reservation:
// an unknown or ambiguous company address is a local data problem, and failing it after
// reserving would consume an EIMS sequence number for an invoice that was never filable. It
// needs no network access, so there is no reason for it to sit behind the login either.
const buyerGeo = resolveMorGeo({
country: invoice.company?.country,
region: invoice.company?.region,
zone: invoice.company?.zone,
woreda: invoice.company?.woreda,
});
// Authenticate before reserving: the source system comes from the token, and the state row is
// keyed by it. A login failure here costs nothing — no counter has been consumed yet.
const session = await this.auth.getSessionContext();
@@ -135,6 +147,7 @@ export class EimsInvoiceRegistrationService {
invoice,
this.sellerCache.getSellerDetails(cfg),
buildEimsContext(cfg, {
buyerGeo,
// Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber
// against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy.
documentNumber: reservation.documentNumber,

View File

@@ -5,11 +5,13 @@ import { ETradeService } from "../companies/services/etrade.service";
import { eimsConfig, eimsInvoiceConfig } from "./eims-test-fixtures";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
// A real MoR address (PARISH_NO 13 / CITY_NO 78 / LOCALITY_NO 1100) — the resolver now works off
// the Ministry's own hierarchy, so a made-up address would simply not resolve.
const registrationData = (over: Record<string, unknown> = {}) => ({
companyName: "Ethio-Djibouti Railway PLC (eTrade)",
region: "Addis Ababa",
zone: "Bole",
woreda: "Yeka",
woreda: "Woreda 1",
mobilePhone: "0911000000",
regularPhone: "",
...over,
@@ -24,16 +26,10 @@ const build = (cfg: EimsConfig = eimsConfig()) => {
return { service, resolveCompanyData, extractRegistrationData, cfg };
};
const CODES = {
buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: { Yeka: "99" },
buyerCityCodes: { Bole: "101" },
};
describe("EimsSellerCacheService.getSellerDetails", () => {
it("static config wins over a conflicting e-Trade value", async () => {
const cfg = eimsConfig({
invoice: eimsInvoiceConfig({ sellerLegalName: "Ethio-Djibouti Railway S.C.", ...CODES }),
invoice: eimsInvoiceConfig({ sellerLegalName: "Ethio-Djibouti Railway S.C." }),
});
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValue({
@@ -56,7 +52,6 @@ describe("EimsSellerCacheService.getSellerDetails", () => {
sellerRegion: "",
sellerWereda: "",
sellerCity: null,
...CODES,
}),
});
const { service, resolveCompanyData } = build(cfg);
@@ -67,13 +62,32 @@ describe("EimsSellerCacheService.getSellerDetails", () => {
expect(seller.LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
expect(seller.Region).toBe("13");
expect(seller.Wereda).toBe("99");
expect(seller.City).toBe("78");
expect(seller.Wereda).toBe("1100");
});
it("leaves the static seller values alone when MoR does not list the e-Trade address", async () => {
// e-Trade's free text does not always correspond to a MoR row (here "Yeka" is a MoR *City*
// under ADDIS ABABA, not a locality under BOLE). That must degrade to the static config, which
// MoR has already cleared under rule 7017 — never throw, and never file a guessed code.
const cfg = eimsConfig({
invoice: eimsInvoiceConfig({ sellerRegion: "1", sellerWereda: "13", sellerCity: "101" }),
});
const { service, resolveCompanyData, extractRegistrationData } = build(cfg);
extractRegistrationData.mockReturnValue(registrationData({ woreda: "Yeka" }));
resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} });
await expect(service.refresh()).resolves.toBeUndefined();
const seller = service.getSellerDetails(cfg);
expect(seller.Region).toBe("1");
expect(seller.Wereda).toBe("13");
expect(seller.City).toBe("101");
});
it("VatNumber and Email are always the static value, never touched by e-Trade", async () => {
const cfg = eimsConfig({
invoice: eimsInvoiceConfig({ sellerVatNumber: "0000000000", sellerEmail: "finance@example.et", ...CODES }),
invoice: eimsInvoiceConfig({ sellerVatNumber: "0000000000", sellerEmail: "finance@example.et" }),
});
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} });
@@ -108,7 +122,7 @@ describe("EimsSellerCacheService.getSellerDetails", () => {
describe("EimsSellerCacheService.refresh", () => {
it("keeps the previous snapshot when a refresh fails", async () => {
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) });
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "" }) });
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} });
await service.refresh();
@@ -123,7 +137,7 @@ describe("EimsSellerCacheService.refresh", () => {
it("keeps the previous snapshot on timeout, without waiting for the slow request", async () => {
jest.useFakeTimers();
try {
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) });
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "" }) });
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} });
await service.refresh();

View File

@@ -3,7 +3,8 @@ import { ConfigService } from "@nestjs/config";
import { EimsConfig } from "../../config/eims.config";
import { ETradeService } from "../companies/services/etrade.service";
import { EimsSellerDetails, resolveOptionalCode } from "../billing/eims-invoice.mapper";
import { tryResolveMorGeo } from "../../config/mor-location.resolver";
import { EimsSellerDetails } from "../billing/eims-invoice.mapper";
import { buildEimsSeller } from "./eims-invoice-context";
const has = (value: string | null | undefined): value is string => Boolean(value && value.trim());
@@ -104,17 +105,24 @@ export class EimsSellerCacheService implements OnModuleInit {
);
if (!businessInfo) return; // no licence on file yet — keep the previous snapshot
const data = this.etrade.extractRegistrationData(businessInfo, companyInfo);
const codes = cfg.invoice;
// e-Trade returns region/zone/woreda as names ("Addis Ababa", "Bole") — resolved through the
// same MoR location master the buyer side uses, since the geography is objective, not
// buyer-specific. `tryResolveMorGeo` never throws: an address MoR does not list simply
// leaves these fields to getSellerDetails' static-config fallback, which is authoritative
// anyway (see the class comment — MoR has already cleared the static seller values under
// rule 7017, so nothing here may override one). e-Trade carries no country field; the
// resolver reads a blank country as domestic, which is correct for EDR's own registration.
const geo = tryResolveMorGeo({
region: data.region,
zone: data.zone,
woreda: data.woreda,
});
this.cached = {
LegalName: data.companyName || undefined,
Phone: data.mobilePhone || data.regularPhone || undefined,
// e-Trade returns region/zone/woreda as names ("Addis Ababa", "Bole") — resolved via the
// same buyer code maps, since the geography is objective, not buyer-specific, despite the
// env var's "BUYER_" prefix. Never throws: an unmapped name just leaves that field to
// getSellerDetails' static-config fallback.
Region: resolveOptionalCode(data.region, codes.buyerRegionCodes),
Wereda: resolveOptionalCode(data.woreda, codes.buyerWeredaCodes),
City: resolveOptionalCode(data.zone, codes.buyerCityCodes),
Region: geo?.Region,
Wereda: geo?.Wereda,
City: geo?.City,
};
} catch (err) {
this.logger.warn(

View File

@@ -32,11 +32,6 @@ export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsIn
paymentMode: "CASH",
paymentTerm: "IMMIDIATE",
unitDefault: "PCS",
buyerCountryCode: null,
buyerCountryCodes: { Ethiopia: "231" }, // test-only, not a confirmed real MoR code
buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code
buyerCityCodes: { Kirkos: "101" }, // test-only, not a confirmed real MoR code
taxCodeByChargeType: {},
taxRateByChargeType: {},
exciseByChargeType: {},

View File

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

View File

@@ -1,6 +1,10 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { WarehousesModule } from '../warehouses/warehouses.module';
import { DjiboutiIncident } from './entities/djibouti-incident.entity';
import { EmptyContainerReturn } from './entities/empty-container-return.entity';
import { ImportCustomsFinalization } from './entities/import-customs-finalization.entity';
@@ -14,6 +18,14 @@ import { ImportOperationsService } from './import-operations.service';
ImportCustomsFinalization,
EmptyContainerReturn,
]),
// WarehouseReleaseDocumentService (the shared PDF renderer) for the
// equipment interchange receipt; BookingsModule for the customer
// ownership check on that same route; the notification modules to tell
// the customer their receipt is ready at handover.
WarehousesModule,
BookingsModule,
NotificationInboxModule,
NotificationsModule,
],
controllers: [ImportOperationsController],
providers: [ImportOperationsService],

View File

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

View File

@@ -32,6 +32,14 @@ export class CreateServiceTypeDto {
@IsBoolean()
includesCustoms?: boolean;
@ApiPropertyOptional({
default: false,
description: 'Customs cleared on the Ethiopian side only (alternative to full includesCustoms; implies it). Prices off the Ethiopian customs rate.',
})
@IsOptional()
@IsBoolean()
includesEthiopianCustomsOnly?: boolean;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()

View File

@@ -16,6 +16,7 @@ describe('deriveRateType — surcharge triggers', () => {
['DEMURRAGE', 'DEMURRAGE'],
['PIL_EXTRA_FEE', 'PIL_EXTRA_FEE'],
['CUSTOMS_CLEARANCE', 'CUSTOMS_CLEARANCE'],
['ETHIOPIAN_CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE'],
] as const)('maps trigger %s to %s', (trigger, expected) => {
expect(deriveRateType({ appliesTo: 'OTHER', trigger })).toBe(expected);
});

View File

@@ -46,6 +46,8 @@ export function deriveRateType(input: {
return 'PIL_EXTRA_FEE';
case 'CUSTOMS_CLEARANCE':
return 'CUSTOMS_CLEARANCE';
case 'ETHIOPIAN_CUSTOMS_CLEARANCE':
return 'ETHIOPIAN_CUSTOMS_CLEARANCE';
case 'FUEL':
return 'FUEL_SURCHARGE';
}

View File

@@ -28,7 +28,7 @@ export const isBulkQuantityUnit = (unit: string): boolean =>
export function allowedRateUnits(input: {
appliesTo: RateAppliesTo;
trigger: RateTrigger;
/** CUSTOMS_CLEARANCE / CANCELLATION only: which cargo kind the fee covers. */
/** Customs clearance / CANCELLATION only: which cargo kind the fee covers. */
cargoKind?: 'CONTAINER' | 'BULK' | null;
/** Unit of measure of the bulk commodity the rate is scoped to, when any. */
cargoUnitOfMeasure?: CargoUom;
@@ -67,6 +67,7 @@ function unitsForShape(input: {
// per wagon is the only unit the wagon-cancel flow can apply.
return ['PER_WAGON'];
case 'CUSTOMS_CLEARANCE':
case 'ETHIOPIAN_CUSTOMS_CLEARANCE':
// Sold per cargo kind: container fees bill per box or per wagon, bulk
// fees per ton or per wagon. Billed on the booking invoice.
return input.cargoKind === 'BULK'

View File

@@ -25,6 +25,7 @@ export const RATE_TYPES = [
'RETURN_SURCHARGE',
'PIL_EXTRA_FEE',
'CUSTOMS_CLEARANCE',
'ETHIOPIAN_CUSTOMS_CLEARANCE',
'FUEL_SURCHARGE',
] as const;
@@ -96,12 +97,22 @@ export const RATE_TRIGGERS = [
// Customs clearance service fee — billed up front via a clearance invoice,
// never auto-applied to booking pricing (matchesTrigger returns false).
'CUSTOMS_CLEARANCE',
// Same shape as CUSTOMS_CLEARANCE; priced instead of it when the booking's
// service type has includesEthiopianCustomsOnly (Ethiopian-side clearance).
'ETHIOPIAN_CUSTOMS_CLEARANCE',
// Fuel surcharge — fires when the booking's cargo type has hasFuel = true,
// billed off the lane-scoped rate (direction + route + cargo type).
'FUEL',
] as const;
export type RateTrigger = typeof RATE_TRIGGERS[number];
/**
* The two customs clearance service fees share one rate shape (per direction +
* route + cargo kind); only which one a booking prices off differs.
*/
export const isCustomsClearanceTrigger = (trigger: string): boolean =>
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'ETHIOPIAN_CUSTOMS_CLEARANCE';
@Entity({ schema: 'freight', name: 'rates' })
@Index(['rateType'])
@Index(['status'])
@@ -117,7 +128,7 @@ export class Rate extends BaseEntity {
@Column({ name: 'applies_to', type: 'varchar', length: 20, default: 'OTHER' })
appliesTo!: RateAppliesTo;
@Column({ name: 'trigger', type: 'varchar', length: 20, default: 'ALWAYS' })
@Column({ name: 'trigger', type: 'varchar', length: 30, default: 'ALWAYS' })
trigger!: RateTrigger;
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })

View File

@@ -27,6 +27,16 @@ export class ServiceType extends BaseEntity {
@Column({ name: 'includes_customs', type: 'boolean', default: false })
includesCustoms!: boolean;
/**
* EDR clears customs on the Ethiopian side only. The admin picks full customs
* OR Ethiopian-only, never both; the API stores includesCustoms = true for
* either so every clearance read (GL review, duty, docs) stays unchanged —
* only the fee differs: pricing looks up ETHIOPIAN_CUSTOMS_CLEARANCE instead
* of CUSTOMS_CLEARANCE.
*/
@Column({ name: 'includes_ethiopian_customs_only', type: 'boolean', default: false })
includesEthiopianCustomsOnly!: boolean;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;

View File

@@ -13,7 +13,7 @@ import { ShippingLineCompaniesService } from '../../shipping-lines/shipping-line
import { CreateRateDto } from '../dto/create-rate.dto';
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
import { UpdateRateDto } from '../dto/update-rate.dto';
import { Rate } from '../entities/rate.entity';
import { Rate, isCustomsClearanceTrigger } from '../entities/rate.entity';
import { deriveRateType } from '../entities/rate-type.util';
import { CargoUom, allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util';
import {
@@ -29,10 +29,15 @@ const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = ['BULK', 'CONTAINE
* Surcharges sold per cargo kind: the admin says container or bulk, a
* container fee then names its container type and a bulk fee its commodity.
*/
const CARGO_KIND_TRIGGERS: readonly Rate['trigger'][] = ['CUSTOMS_CLEARANCE', 'CANCELLATION'];
const CARGO_KIND_TRIGGERS: readonly Rate['trigger'][] = [
'CUSTOMS_CLEARANCE',
'ETHIOPIAN_CUSTOMS_CLEARANCE',
'CANCELLATION',
];
/** Surcharges that keep a trade direction (everything else is direction-agnostic). */
const DIRECTED_SURCHARGE_TRIGGERS: readonly Rate['trigger'][] = [
'CUSTOMS_CLEARANCE',
'ETHIOPIAN_CUSTOMS_CLEARANCE',
'CANCELLATION',
'WITH_RETURN',
'LASHING',
@@ -144,7 +149,7 @@ export class RatesService {
private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
return (
this.isBaseFreight(appliesTo, trigger) ||
trigger === 'CUSTOMS_CLEARANCE' ||
isCustomsClearanceTrigger(trigger) ||
trigger === 'WITH_RETURN' ||
trigger === 'FUEL'
);
@@ -261,7 +266,7 @@ export class RatesService {
}): void {
const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input;
const { containerTypeId, cargoTypeId } = input;
if (trigger === 'CUSTOMS_CLEARANCE' || trigger === 'CANCELLATION') {
if (isCustomsClearanceTrigger(trigger) || trigger === 'CANCELLATION') {
// Both fees are sold per direction + cargo kind + type: customs clearance
// per lane, the wagon cancellation fee per direction only.
const fee = trigger === 'CANCELLATION' ? 'cancellation fee' : 'customs clearance';

View File

@@ -1,5 +1,11 @@
import { PaginatedResponse } from '@edr/types';
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { generateCode } from '../../../common/utils/generate-code.util';
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto';
@@ -43,6 +49,10 @@ export class ServiceTypesService {
const existing = await this.repository.findByCode(code);
if (existing) throw new ConflictException(`Service type with name "${dto.serviceName}" conflicts with existing code "${code}"`);
const customs = this.resolveCustomsFlags(
dto.includesCustoms ?? false,
dto.includesEthiopianCustomsOnly ?? false,
);
const displayOrder = await this.displayOrder.resolveCreateOrder(ServiceType, 'displayOrder', {
explicitOrder: dto.displayOrder,
insertAfterId: dto.insertAfterId,
@@ -55,7 +65,7 @@ export class ServiceTypesService {
canBeBookedAlone: dto.canBeBookedAlone ?? true,
includesFirstMile: dto.includesFirstMile ?? false,
includesLastMile: dto.includesLastMile ?? false,
includesCustoms: dto.includesCustoms ?? false,
...customs,
isActive: dto.isActive ?? true,
displayOrder,
});
@@ -63,13 +73,44 @@ export class ServiceTypesService {
/** Update an existing service type. */
async update(id: string, dto: UpdateServiceTypeDto): Promise<ServiceType> {
await this.findById(id);
const { ...patch } = dto;
const existing = await this.findById(id);
const ethiopian = dto.includesEthiopianCustomsOnly ?? existing.includesEthiopianCustomsOnly;
// The form sends both flags whenever either is touched; a payload with only
// one is a plain edit (name, order…) that keeps the stored pair.
const customs =
dto.includesCustoms !== undefined || dto.includesEthiopianCustomsOnly !== undefined
? this.resolveCustomsFlags(
dto.includesCustoms ?? (existing.includesCustoms && !existing.includesEthiopianCustomsOnly),
ethiopian,
)
: {};
const patch = { ...dto, ...customs };
const updated = await this.repository.update(id, patch);
if (!updated) throw new NotFoundException(`Service type ${id} not found`);
return updated;
}
/**
* Full customs and Ethiopian-only customs are alternatives: the admin picks
* one. Ethiopian-only is still a customs service, so it is stored with
* includesCustoms = true — every clearance read keeps working unchanged and
* only pricing looks at the Ethiopian flag.
*/
private resolveCustomsFlags(
includesCustoms: boolean,
ethiopianOnly: boolean,
): Pick<ServiceType, 'includesCustoms' | 'includesEthiopianCustomsOnly'> {
if (includesCustoms && ethiopianOnly) {
throw new BadRequestException(
'Pick either "Includes customs" or "Ethiopian customs only", not both.',
);
}
return {
includesCustoms: includesCustoms || ethiopianOnly,
includesEthiopianCustomsOnly: ethiopianOnly,
};
}
/** Soft-delete a service type. */
async remove(id: string): Promise<void> {
await this.findById(id);

View File

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

View File

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

View File

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

View File

@@ -246,6 +246,71 @@ describe('BookingBatchService — PAID reconcile', () => {
expect(reconcileOrder).toBeLessThan(wagonOrder);
});
describe('expire — consolidated pair, one side paid', () => {
const pairBooking = (id: string, partnerId: string, paid: boolean): Booking =>
({
id,
reference: id,
status: paid ? 'PAID' : 'SELECTED_FOR_BATCH',
paymentStatus: paid ? 'PAID' : 'PENDING',
consolidationPartnerId: partnerId,
trainScheduleId: null,
bookingContainers: [],
}) as unknown as Booking;
let emit: jest.Mock;
let unpaid: Booking;
let paid: Booking;
beforeEach(() => {
unpaid = pairBooking('unpaid-1', 'paid-1', false);
paid = pairBooking('paid-1', 'unpaid-1', true);
emit = jest.fn();
(service as unknown as { eventEmitter: { emit: jest.Mock } }).eventEmitter = { emit };
(bookingsRepository as unknown as { clearConsolidationPair: jest.Mock }).clearConsolidationPair =
jest.fn().mockResolvedValue(undefined);
dataSource.getRepository().findOne.mockImplementation(
async ({ where }: { where: { id: string } }) =>
where.id === 'paid-1' ? paid : unpaid,
);
});
it('expires the unpaid side fee-free and cancels the PAID partner via partnerLapsed', async () => {
await (service as unknown as { expire(b: Booking): Promise<void> }).expire(unpaid);
// Paid partner is NOT rescued onto a train — the listener cancels it with the fee.
expect(emit).toHaveBeenCalledWith('booking.consolidation.partnerLapsed', {
paidBookingId: 'paid-1',
});
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
// The unpaid side itself just expires.
expect(bookingsRepository.update).toHaveBeenCalledWith(
'unpaid-1',
expect.objectContaining({ status: 'EXPIRED' }),
);
expect(notifier.expired).toHaveBeenCalledTimes(1);
});
it('wrong side called first: PAID booking is cancelled via partnerLapsed, never rescued', async () => {
await (service as unknown as { expire(b: Booking): Promise<void> }).expire(paid);
expect(emit).toHaveBeenCalledWith('booking.consolidation.partnerLapsed', {
paidBookingId: 'paid-1',
});
// The unpaid partner expired fee-free…
expect(bookingsRepository.update).toHaveBeenCalledWith(
'unpaid-1',
expect.objectContaining({ status: 'EXPIRED' }),
);
// …and the paid side was neither expired nor allocated here.
expect(bookingsRepository.update).not.toHaveBeenCalledWith(
'paid-1',
expect.objectContaining({ status: 'EXPIRED' }),
);
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
});
});
describe('extendPaymentPhaseForTopUp', () => {
const schedRepo = () => dataSource.getRepository();
@@ -1536,4 +1601,72 @@ describe('BookingBatchService — physical wagon-type gate', () => {
// Those 16 are now held, so the next booking in the pass cannot re-take them.
expect(stock.availableFor([NW5], WHOLE_LEG)).toBe(0);
});
it('sizes a capped-bulk partial on ONE type at the cargo cap, not the 70T rating', async () => {
const svc = service();
const inner = internals(svc);
(inner as { isSplitEligible: unknown }).isSplitEligible = () => true;
const dims = { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 };
(inner as unknown as { loadWagonDims: unknown }).loadWagonDims = async () => ({
container: dims,
bulk: dims,
byWagonTypeId: new Map([
[NW5, dims],
[PW2, dims],
]),
});
const tryPartial = jest
.fn()
.mockResolvedValue({ wagons: 16, weightTons: 864, lengthMeters: 224 });
(inner as { tryPartialOffer: unknown }).tryPartialOffer = tryPartial;
const stock = mixedStock();
const candidate = {
id: 'schedule-1',
budget: {
legOf: () => WHOLE_LEG,
remainingFor: () => ({ wagons: 20, weightTons: 99_999, lengthMeters: 99_999 }),
subtract: jest.fn(),
},
armed: false,
stock,
};
const booking = {
id: 'b2',
reference: 'BK-2',
originYardId: 'a',
destinationYardId: 'b',
freightType: 'BULK',
cargoTotalWeightVgm: 695,
cargoType: {
id: 'cargo-perishable',
wagonTypes: [
{ id: NW5, capacityTons: 70 },
{ id: PW2, capacityTons: 70 },
],
tonsPerWagonMap: { [NW5]: 30, [PW2]: 20 },
},
bookingContainers: [],
} as unknown as Booking;
const offered = await inner.maybeOfferPartial(
booking,
false,
[candidate],
{ wagons: 24, weightTons: 1400, lengthMeters: 336 },
[NW5, PW2],
);
expect(offered).toBe(true);
// Room capped to the 16 NW5 that exist (biggest capped take), and the seat
// carries the 30T cargo cap — never the wagon's raw 70T rating.
expect(tryPartial.mock.calls[0][2]).toMatchObject({ wagons: 16 });
expect(tryPartial.mock.calls[0][4]).toMatchObject({
wagonTypeId: NW5,
perWagon: { capacityTons: 30 },
});
// Only the seated type is held; the PW2s stay free for bulk-only cargo.
expect(stock.availableFor([NW5], WHOLE_LEG)).toBe(0);
expect(stock.availableFor([PW2], WHOLE_LEG)).toBe(4);
});
});

View File

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

View File

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

View File

@@ -0,0 +1,115 @@
import { BookingBatchService } from './booking-batch.service';
import { CorridorBudget } from './corridor-capacity.util';
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
import { Booking } from '../bookings/entities/booking.entity';
/**
* Regression: a train with 15 wagons planned at Mojo and a 15-wagon booking
* already committed from Mojo advertised "15 free at Mojo" — the whole-train
* corridor budget still had room on that edge (GMP's wagons), and the per-yard
* stock ledger was born full. The ledger must be debited by the SAME committed
* bookings the budget subtracts.
*/
describe('BookingBatchService — per-yard stock ledger debits committed bookings', () => {
const GMP = 'gmp', MOJO = 'mojo', DCT = 'dct';
const booking = (id: string, originYardId: string, wagonsRequired: number) =>
({
id,
freightType: 'BULK',
cargoTypeId: 'ct-coffee',
wagonsRequired,
originYardId,
destinationYardId: DCT,
cargoTotalWeightVgm: 1,
bookingContainers: [],
}) as unknown as Booking;
const schedule = {
id: 'S-35',
routeId: 'route-1',
originStationId: GMP,
destinationStationId: DCT,
scheduleBookings: [{ booking: booking('BK-118', MOJO, 15) }, { booking: booking('BK-120', GMP, 1) }],
} as never;
const makeService = (pendingHolds: Booking[] = []) => {
const milestoneRepo = {
find: jest.fn().mockResolvedValue([
{ yardId: GMP, sequenceNo: 1 },
{ yardId: MOJO, sequenceNo: 2 },
{ yardId: DCT, sequenceNo: 3 },
]),
};
const emptyRepo = { find: jest.fn().mockResolvedValue([]) };
// Booking.find is only used for OPERATION_REQUEST_PENDING export holds.
const bookingRepo = { find: jest.fn().mockResolvedValue(pendingHolds) };
const dataSource = {
getRepository: jest.fn((entity: unknown) =>
entity === RouteMilestone ? milestoneRepo : entity === Booking ? bookingRepo : emptyRepo,
),
query: jest.fn(async (sql: string) =>
sql.includes('cargo_type_wagon_types') ? [{ typeId: 'ct-coffee', wagonTypeId: 'nw5' }] : [],
),
};
const trainSchedulingService = {
wagonStockForSchedule: jest.fn().mockResolvedValue({
mode: 'TRAIN',
remainingByTypeId: new Map([['nw5', 46]]),
codesByTypeId: new Map([['nw5', 'NW5']]),
byYardId: new Map([
[GMP, new Map([['nw5', 31]])],
[MOJO, new Map([['nw5', 15]])],
]),
}),
};
return new BookingBatchService(
dataSource as never,
{ findReservedForSchedule: jest.fn().mockResolvedValue([]) } as never,
{} as never,
{} as never,
{} as never,
{} as never,
trainSchedulingService as never,
{} as never,
{} as never,
{} as never,
);
};
const budget = () =>
new CorridorBudget([GMP, MOJO, DCT], {
wagons: 46,
weightTons: Number.POSITIVE_INFINITY,
lengthMeters: Number.POSITIVE_INFINITY,
});
it('shows 0 free at Mojo once its 15 planned wagons are booked, while GMP keeps its own', async () => {
const service = makeService() as unknown as {
stockLedgerFor: BookingBatchService['stockLedgerFor'];
};
const b = budget();
const ledger = await service.stockLedgerFor(schedule, b);
expect(ledger.availableFor(['nw5'], b.legOf(MOJO, DCT)!)).toBe(0);
expect(ledger.availableFor(['nw5'], b.legOf(GMP, DCT)!)).toBe(30);
});
it('a pending export request already holds its wagons at its yard (before staff accept)', async () => {
const service = makeService([booking('BK-REQ', MOJO, 10)]) as unknown as {
stockLedgerFor: BookingBatchService['stockLedgerFor'];
};
const emptySchedule = { ...(schedule as object), scheduleBookings: [] } as never;
const b = budget();
const ledger = await service.stockLedgerFor(emptySchedule, b);
expect(ledger.availableFor(['nw5'], b.legOf(MOJO, DCT)!)).toBe(5);
expect(ledger.availableFor(['nw5'], b.legOf(GMP, DCT)!)).toBe(31);
});
it('excludes the booking being evaluated so a request never blocks its own accept', async () => {
const service = makeService() as unknown as {
stockLedgerFor: BookingBatchService['stockLedgerFor'];
};
const b = budget();
const ledger = await service.stockLedgerFor(schedule, b, ['BK-118']);
expect(ledger.availableFor(['nw5'], b.legOf(MOJO, DCT)!)).toBe(15);
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -56,6 +56,14 @@ export class AssignBookingsDto {
@IsBoolean()
forceAssign?: boolean;
@ApiPropertyOptional({
description:
'Linked bookings the plan cannot seat stay linked as WAITING_FOR_WAGON instead of failing the whole allocation (auto-allocation mode). Requested bookingIds still fail loudly.',
})
@IsOptional()
@IsBoolean()
keepDeferredLinked?: boolean;
@ApiPropertyOptional({ type: [ContainerPlacementDto] })
@IsOptional()
@IsArray()

View File

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

View File

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

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