mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' of https://github.com/Tria-plc/edr-platform into feature/group-booking
This commit is contained in:
BIN
4_5767239985799371288.xlsx
Normal file
BIN
4_5767239985799371288.xlsx
Normal file
Binary file not shown.
BIN
EDR-Freight-Priority-Flows-Portal.pdf
Normal file
BIN
EDR-Freight-Priority-Flows-Portal.pdf
Normal file
Binary file not shown.
BIN
EDR-Freight-Priority-Flows.pdf
Normal file
BIN
EDR-Freight-Priority-Flows.pdf
Normal file
Binary file not shown.
BIN
EDR-Freight-User-Guide.pdf
Normal file
BIN
EDR-Freight-User-Guide.pdf
Normal file
Binary file not shown.
BIN
INV-20260812-00005-QR.png
Normal file
BIN
INV-20260812-00005-QR.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
BIN
INV-20260812-00005.pdf
Normal file
BIN
INV-20260812-00005.pdf
Normal file
Binary file not shown.
@@ -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).
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
339
apps/edr-freight-api/scripts/run-legboard-tests.ts
Normal file
339
apps/edr-freight-api/scripts/run-legboard-tests.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* 10 scenarios against the REAL S-2026-00045 consist (42 NW5 + 10 PW2) on the
|
||||
* real DCT -> DIRE_DAWA -> GMP corridor, checking the three reported issues:
|
||||
*
|
||||
* 1. LEG BOARD truthfulness — can you tell which booking rides which leg?
|
||||
* 2. CONSIST DIAGRAM — does a shared wagon expose one row per load?
|
||||
* 3. PARTIAL OFFER — when only the 10 PW2 are free, is the customer offered
|
||||
* the part that fits instead of being dropped silently?
|
||||
*
|
||||
* Read-only. Run: npx ts-node -T scripts/run-legboard-tests.ts
|
||||
*/
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import {
|
||||
planWagonsWithStock,
|
||||
type AllowedWagonTypeMap,
|
||||
type WagonStock,
|
||||
} from '../src/modules/train-scheduling/wagon-plan-flex.util';
|
||||
import { validateWagonCargoExclusivity } from '../src/modules/train-scheduling/utils/wagon-plan.util';
|
||||
import { sizePartialOfferWagons, bulkTonsPerWagon } from '../src/modules/train-scheduling/train-capacity.util';
|
||||
import type { Booking } from '../src/modules/bookings/entities/booking.entity';
|
||||
import type { WagonType } from '../src/modules/wagon-types/entities/wagon-type.entity';
|
||||
|
||||
const YARD = {
|
||||
DCT: 'fc558b95-da28-4fc3-8348-311a290c34ae',
|
||||
DIRE: 'f7b1686f-d43e-42aa-bc6a-d3d5849296c9',
|
||||
GMP: '61ae1e66-c229-4dcd-9851-b2b9424f3a95',
|
||||
};
|
||||
const STOPS = [YARD.DCT, YARD.DIRE, YARD.GMP];
|
||||
const STOP_NAME = ['DCT', 'DIRE', 'GMP'];
|
||||
const NW5 = '8f717b09-eec1-46ad-be3d-2dc0a56e55e7';
|
||||
const PW2 = '8eec3a7d-8482-4397-96b6-59a028210722';
|
||||
const PERISHABLE = 'a5991d3a-d690-4b7e-98fd-ea3333aa16e7';
|
||||
const FT40 = '349072e7-8a90-4c03-b682-08976abfd7e8';
|
||||
|
||||
const ds = new DataSource({
|
||||
type: 'postgres',
|
||||
host: process.env.DB_HOST || '10.18.7.207',
|
||||
port: Number(process.env.DB_PORT || 5432),
|
||||
database: process.env.DB_NAME || 'edr_dev',
|
||||
username: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'dcba@1234',
|
||||
});
|
||||
|
||||
type Check = { name: string; pass: boolean; detail: string };
|
||||
const groups: Array<{ scenario: string; checks: Check[] }> = [];
|
||||
const add = (l: Check[], name: string, pass: boolean, detail: string) =>
|
||||
l.push({ name, pass, detail });
|
||||
|
||||
let TYPES: WagonType[] = [];
|
||||
let ALLOWED: AllowedWagonTypeMap;
|
||||
let CODES = new Map<string, string>();
|
||||
let CARGO: any;
|
||||
|
||||
async function loadConfig() {
|
||||
TYPES = await ds.query(
|
||||
`SELECT id, code, name, capacity_tons AS "capacityTons",
|
||||
length_meters AS "lengthMeters", tare_weight_tons AS "tareWeightTons",
|
||||
supports_container AS "supportsContainer", is_active AS "isActive"
|
||||
FROM freight.wagon_types WHERE is_active IS NOT FALSE`);
|
||||
const byId = new Map(TYPES.map((t) => [t.id, t]));
|
||||
CODES = new Map(TYPES.map((t) => [t.id, t.code]));
|
||||
const rows = async (sql: string) => {
|
||||
const r: Array<{ typeId: string; wagonTypeId: string }> = await ds.query(sql);
|
||||
const m = new Map<string, WagonType[]>();
|
||||
for (const x of r) {
|
||||
const wt = byId.get(x.wagonTypeId);
|
||||
if (wt) m.set(x.typeId, [...(m.get(x.typeId) ?? []), wt]);
|
||||
}
|
||||
return m;
|
||||
};
|
||||
ALLOWED = {
|
||||
byCargoTypeId: await rows(
|
||||
`SELECT cargo_type_id AS "typeId", wagon_type_id AS "wagonTypeId" FROM freight.cargo_type_wagon_types`),
|
||||
byContainerTypeId: await rows(
|
||||
`SELECT container_type_id AS "typeId", wagon_type_id AS "wagonTypeId" FROM freight.container_type_wagon_types`),
|
||||
};
|
||||
const [c] = await ds.query(
|
||||
`SELECT id, cargo_type_name AS "cargoTypeName", code, unit_of_measure AS "unitOfMeasure",
|
||||
tons_per_wagon_map AS "tonsPerWagonMap", items_per_wagon_map AS "itemsPerWagonMap"
|
||||
FROM freight.cargo_types WHERE id=$1`, [PERISHABLE]);
|
||||
CARGO = { ...c, wagonTypes: ALLOWED.byCargoTypeId.get(PERISHABLE) ?? [] };
|
||||
}
|
||||
|
||||
let seq = 0;
|
||||
const bulk = (ref: string, tons: number, from: string, to: string, paid = false): Booking =>
|
||||
({
|
||||
id: `bulk-${(seq += 1)}`, reference: ref, freightType: 'BULK',
|
||||
cargoTotalWeightVgm: tons, cargoTypeId: PERISHABLE, cargoType: CARGO,
|
||||
originYardId: from, destinationYardId: to, bookingContainers: [],
|
||||
status: paid ? 'PAID' : 'CLEARANCE_READY',
|
||||
paymentStatus: paid ? 'PAID' : 'PENDING',
|
||||
}) as unknown as Booking;
|
||||
|
||||
const cont = (ref: string, qty: number, from: string, to: string, paid = false): Booking =>
|
||||
({
|
||||
id: `cont-${(seq += 1)}`, reference: ref, freightType: 'CONTAINER',
|
||||
cargoTotalWeightVgm: qty * 26, originYardId: from, destinationYardId: to,
|
||||
status: paid ? 'PAID' : 'CLEARANCE_READY',
|
||||
paymentStatus: paid ? 'PAID' : 'PENDING',
|
||||
bookingContainers: [{
|
||||
id: `line-${seq}`, containerTypeId: FT40, quantity: qty,
|
||||
vgmPerUnitTons: 26, wagonsRequired: qty,
|
||||
containerType: { id: FT40, code: '40FT', sizeFt: 40 }, units: [],
|
||||
}],
|
||||
}) as unknown as Booking;
|
||||
|
||||
const stockOf = (nw5: number, pw2: number): WagonStock => ({
|
||||
mode: 'TRAIN',
|
||||
remainingByTypeId: new Map([[NW5, nw5], [PW2, pw2]]),
|
||||
codesByTypeId: CODES,
|
||||
});
|
||||
|
||||
const legsOf = (bs: Booking[]) =>
|
||||
new Map(bs.map((b: any) => [b.id,
|
||||
{ from: STOPS.indexOf(b.originYardId), to: STOPS.indexOf(b.destinationYardId) }]));
|
||||
|
||||
const plan = (bs: Booking[], stock: WagonStock) =>
|
||||
planWagonsWithStock({
|
||||
bookings: bs, allowed: ALLOWED, stock, legs: legsOf(bs),
|
||||
edgeCount: 2, stops: STOPS,
|
||||
});
|
||||
|
||||
const byType = (p: any[]) => {
|
||||
const c = new Map<string, number>();
|
||||
for (const s of p) c.set(s.wagonTypeCode, (c.get(s.wagonTypeCode) ?? 0) + 1);
|
||||
return [...c.entries()].map(([k, n]) => `${n}x ${k}`).join(', ') || 'none';
|
||||
};
|
||||
|
||||
/**
|
||||
* What the LEG BOARD can actually say about a slot, given only the fields it
|
||||
* reads (boardYardId / alightYardId) — this is the UI's own view.
|
||||
*/
|
||||
const stampSpan = (slot: any, bs: Booking[]) => {
|
||||
const byId = new Map(bs.map((b: any) => [b.id, b]));
|
||||
const sb = [...new Set(slot.allocations.map((a: any) => a.bookingId))]
|
||||
.map((id) => byId.get(id as string)).filter(Boolean) as any[];
|
||||
if (!sb.length) return null;
|
||||
const first = sb[0];
|
||||
const same = sb.every((b) =>
|
||||
b.originYardId === first.originYardId && b.destinationYardId === first.destinationYardId);
|
||||
if (same) {
|
||||
return {
|
||||
from: STOPS.indexOf(first.originYardId),
|
||||
to: STOPS.indexOf(first.destinationYardId),
|
||||
union: false,
|
||||
};
|
||||
}
|
||||
let from = Infinity, to = -Infinity;
|
||||
for (const b of sb) {
|
||||
from = Math.min(from, STOPS.indexOf(b.originYardId));
|
||||
to = Math.max(to, STOPS.indexOf(b.destinationYardId));
|
||||
}
|
||||
return { from, to, union: true };
|
||||
};
|
||||
|
||||
async function main() {
|
||||
await ds.initialize();
|
||||
await loadConfig();
|
||||
|
||||
console.log('\n=== REAL CONSIST (S-2026-00045): 42 NW5 + 10 PW2 ===');
|
||||
console.log('Corridor: DCT -> DIRE -> GMP (edge 0 = DCT->DIRE, edge 1 = DIRE->GMP)\n');
|
||||
|
||||
// ============================================================ SCENARIO 1
|
||||
// The reported shape: 42x40ft intercity Dire->GMP + bulk DCT->GMP.
|
||||
{
|
||||
const checks: Check[] = [];
|
||||
const bs = [cont('IC-42', 42, YARD.DIRE, YARD.GMP), bulk('BULK-695', 695, YARD.DCT, YARD.GMP, true)];
|
||||
const r = plan(bs, stockOf(42, 10));
|
||||
console.log('── S1: 42x40ft Dire->GMP (intercity) + 695T bulk DCT->GMP (paid)');
|
||||
console.log(` plan ${r.plan.length} wagons — ${byType(r.plan)}`);
|
||||
for (const d of r.deferred) console.log(` DEFERRED ${d.reference}: ${d.reason}`);
|
||||
|
||||
// Every slot must say, truthfully, which legs it is busy on.
|
||||
const shared = r.plan.filter((s) => {
|
||||
const refs = new Set(s.allocations.map((a) => a.bookingReference));
|
||||
return refs.size > 1;
|
||||
});
|
||||
add(checks, 'shared wagons expose EVERY load (diagram can stack them)',
|
||||
shared.every((s) => s.allocations.length >= 2),
|
||||
`${shared.length} shared wagon(s); allocations per shared wagon: ${
|
||||
shared.map((s) => s.allocations.length).join(',') || 'n/a'}`);
|
||||
|
||||
// The leg-board reads ONE span per slot. For a mixed-corridor wagon that
|
||||
// span is the UNION, which cannot say which load rides which leg.
|
||||
const mixed = r.plan.map((s) => stampSpan(s, bs)).filter((x) => x?.union);
|
||||
add(checks, 'no wagon needs a UNION span (leg board stays truthful)',
|
||||
mixed.length === 0,
|
||||
mixed.length
|
||||
? `${mixed.length} wagon(s) carry different corridors -> leg board shows one merged bar`
|
||||
: 'every wagon carries a single corridor');
|
||||
add(checks, 'per-edge exclusivity holds',
|
||||
validateWagonCargoExclusivity(r.plan, legsOf(bs), 2).length === 0, 'clean');
|
||||
groups.push({ scenario: 'S1 — intercity 42x40ft + paid bulk (the reported case)', checks });
|
||||
}
|
||||
|
||||
// ============================================================ SCENARIO 2
|
||||
// Only the 10 PW2 are free — the staging complaint.
|
||||
{
|
||||
const checks: Check[] = [];
|
||||
const b = bulk('BULK-695', 695, YARD.DCT, YARD.GMP, true);
|
||||
const r = plan([b], stockOf(0, 10));
|
||||
console.log('\n── S2: 695T bulk, ONLY 10 PW2 free (0 NW5)');
|
||||
console.log(` plan ${r.plan.length} wagons — ${byType(r.plan)}`);
|
||||
for (const d of r.deferred) console.log(` DEFERRED ${d.reference}: ${d.reason}`);
|
||||
|
||||
add(checks, 'whole booking correctly refused (10 PW2 x 20T = 200T < 695T)',
|
||||
r.deferred.length === 1 && r.fitting.length === 0,
|
||||
r.deferred[0]?.reason ?? 'unexpectedly fitted');
|
||||
|
||||
// What SHOULD happen: offer the part that fits on those 10 PW2.
|
||||
const perWagonTons = bulkTonsPerWagon(CARGO, PW2, 70);
|
||||
const pw2Type = TYPES.find((t) => t.id === PW2)!;
|
||||
const wholeWagons = Math.ceil(695 / perWagonTons);
|
||||
const offer = sizePartialOfferWagons(
|
||||
{ wagons: 10, weightTons: 3500, lengthMeters: 1520 },
|
||||
wholeWagons,
|
||||
{ capacityTons: perWagonTons,
|
||||
tareWeightTons: Number(pw2Type.tareWeightTons),
|
||||
lengthMeters: Number(pw2Type.lengthMeters) },
|
||||
{ fullWagonsOnly: true },
|
||||
);
|
||||
console.log(` partial offer sizing: ${offer ? `${offer.wagons} PW2 = ${offer.maxCargoTons}T of 695T` : 'NONE'}`);
|
||||
add(checks, 'a partial offer of 10 PW2 (200T) is sizeable for the customer',
|
||||
offer != null && offer.wagons === 10 && offer.maxCargoTons === 200,
|
||||
offer ? `${offer.wagons} PW2 x ${perWagonTons}T = ${offer.maxCargoTons}T` : 'no offer could be sized');
|
||||
groups.push({ scenario: 'S2 — only 10 PW2 free: split offer instead of silent drop', checks });
|
||||
}
|
||||
|
||||
// ============================================================ SCENARIO 3-10
|
||||
const cases: Array<{ name: string; bookings: Booking[]; stock: WagonStock; expectFit?: string[] }> = [
|
||||
{ name: 'S3 — bulk DCT->DIRE + bulk DIRE->GMP (disjoint, reuse expected)',
|
||||
bookings: [bulk('B-L1', 200, YARD.DCT, YARD.DIRE), bulk('B-L2', 200, YARD.DIRE, YARD.GMP)],
|
||||
stock: stockOf(7, 0) },
|
||||
{ name: 'S4 — container DCT->DIRE then bulk DIRE->GMP on same wagons',
|
||||
bookings: [cont('C-L1', 7, YARD.DCT, YARD.DIRE), bulk('B-L2', 210, YARD.DIRE, YARD.GMP)],
|
||||
stock: stockOf(7, 0) },
|
||||
{ name: 'S5 — three corridors at once (DCT->DIRE, DIRE->GMP, DCT->GMP)',
|
||||
bookings: [cont('C-A', 5, YARD.DCT, YARD.DIRE), cont('C-B', 5, YARD.DIRE, YARD.GMP),
|
||||
bulk('B-FULL', 150, YARD.DCT, YARD.GMP, true)],
|
||||
stock: stockOf(12, 10) },
|
||||
{ name: 'S6 — paid bulk vs unpaid containers, scarce NW5',
|
||||
bookings: [cont('C-UNPAID', 20, YARD.DCT, YARD.GMP), bulk('B-PAID', 300, YARD.DCT, YARD.GMP, true)],
|
||||
stock: stockOf(20, 10) },
|
||||
{ name: 'S7 — two paid bulks competing for the same PW2',
|
||||
bookings: [bulk('B-P1', 200, YARD.DCT, YARD.GMP, true), bulk('B-P2', 200, YARD.DCT, YARD.GMP, true)],
|
||||
stock: stockOf(5, 10) },
|
||||
{ name: 'S8 — bulk exactly filling the PW2 (200T)',
|
||||
bookings: [bulk('B-EXACT', 200, YARD.DCT, YARD.GMP, true)],
|
||||
stock: stockOf(0, 10) },
|
||||
{ name: 'S9 — 1T over the PW2 capacity (201T)',
|
||||
bookings: [bulk('B-OVER', 201, YARD.DCT, YARD.GMP, true)],
|
||||
stock: stockOf(0, 10) },
|
||||
{ name: 'S10 — full train: containers both legs + bulk through',
|
||||
bookings: [cont('C-1', 42, YARD.DCT, YARD.DIRE), cont('C-2', 42, YARD.DIRE, YARD.GMP),
|
||||
bulk('B-THRU', 200, YARD.DCT, YARD.GMP, true)],
|
||||
stock: stockOf(42, 10) },
|
||||
];
|
||||
|
||||
for (const c of cases) {
|
||||
const checks: Check[] = [];
|
||||
const r = plan(c.bookings, c.stock);
|
||||
const legs = legsOf(c.bookings);
|
||||
console.log(`\n── ${c.name}`);
|
||||
console.log(` plan ${r.plan.length} wagons — ${byType(r.plan)}`);
|
||||
console.log(` fitting: ${r.fitting.map((b) => b.reference).join(', ') || 'none'}`);
|
||||
for (const d of r.deferred) console.log(` DEFERRED ${d.reference}: ${d.reason}`);
|
||||
for (const s of r.plan.filter((x) => x.allocations.length > 1)) {
|
||||
const spans = s.allocations.map((a) => {
|
||||
const l = legs.get(a.bookingId)!;
|
||||
return `${a.bookingReference}[${STOP_NAME[l.from]}->${STOP_NAME[l.to]}]`;
|
||||
});
|
||||
console.log(` wagon #${s.sequenceNo} ${s.wagonTypeCode}: ${spans.join(' + ')}`);
|
||||
}
|
||||
|
||||
add(checks, 'per-edge cargo exclusivity holds',
|
||||
validateWagonCargoExclusivity(r.plan, legs, 2).length === 0,
|
||||
validateWagonCargoExclusivity(r.plan, legs, 2).join('; ') || 'clean');
|
||||
add(checks, 'every planned wagon carries at least one allocation',
|
||||
r.plan.every((s) => s.allocations.length > 0),
|
||||
`${r.plan.filter((s) => !s.allocations.length).length} empty slot(s)`);
|
||||
// Per-EDGE cap: a wagon reused on two disjoint legs carries its cap on
|
||||
// each leg, so assignedWeightTons (the whole-journey sum) may exceed one
|
||||
// leg's cap legitimately. Check the heaviest single leg instead.
|
||||
const overCap = r.plan.filter((s) => s.slotLoadType === 'BULK').filter((s) => {
|
||||
const cap = bulkTonsPerWagon(CARGO, s.wagonTypeId, Number(s.capacityTons));
|
||||
const perEdge = [0, 0];
|
||||
for (const a of s.allocations) {
|
||||
const l = legs.get(a.bookingId)!;
|
||||
for (let e = l.from; e < l.to; e += 1) perEdge[e] += a.allocatedWeightTons;
|
||||
}
|
||||
return Math.max(...perEdge) > cap + 0.001;
|
||||
});
|
||||
add(checks, 'no bulk wagon exceeds its cargo cap on any single leg',
|
||||
overCap.length === 0,
|
||||
overCap.length ? `${overCap.length} wagon(s) over cap` : 'caps respected per leg');
|
||||
// Leg-board truthfulness for every shared wagon.
|
||||
// The leg board no longer relies on the slot's union span: each allocation
|
||||
// carries its booking's own yards, so the UI splits a shared wagon into one
|
||||
// bar per corridor. Assert that data IS derivable for every shared wagon.
|
||||
const unionSpans = r.plan.map((s) => stampSpan(s, c.bookings)).filter((x) => x?.union);
|
||||
const splittable = r.plan
|
||||
.filter((s) => s.allocations.length > 1)
|
||||
.every((s) => {
|
||||
const corridors = new Set(
|
||||
s.allocations.map((a) => {
|
||||
const l = legs.get(a.bookingId);
|
||||
return l ? `${l.from}-${l.to}` : 'unknown';
|
||||
}),
|
||||
);
|
||||
return !corridors.has('unknown');
|
||||
});
|
||||
add(checks, 'leg board can name the leg of every load (per-allocation corridors)',
|
||||
splittable,
|
||||
unionSpans.length
|
||||
? `${unionSpans.length} wagon(s) span a union — UI splits them into ${
|
||||
[...new Set(r.plan.flatMap((s) => s.allocations.map((a) => {
|
||||
const l = legs.get(a.bookingId)!;
|
||||
return `${STOP_NAME[l.from]}->${STOP_NAME[l.to]}`;
|
||||
})))].join(' | ')} bars`
|
||||
: 'no shared wagons; spans already exact');
|
||||
groups.push({ scenario: c.name, checks });
|
||||
}
|
||||
|
||||
console.log('\n\n================ REPORT ================');
|
||||
let pass = 0, fail = 0;
|
||||
for (const g of groups) {
|
||||
console.log(`\n${g.scenario}`);
|
||||
for (const c of g.checks) {
|
||||
console.log(` ${c.pass ? 'PASS' : 'FAIL'} ${c.name}\n ${c.detail}`);
|
||||
c.pass ? (pass += 1) : (fail += 1);
|
||||
}
|
||||
}
|
||||
console.log(`\n----------------------------------------`);
|
||||
console.log(`TOTAL: ${pass} passed, ${fail} failed`);
|
||||
await ds.destroy();
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
236
apps/edr-freight-api/scripts/run-s45-scenario.ts
Normal file
236
apps/edr-freight-api/scripts/run-s45-scenario.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Reproduces the reported S-2026-00045 failure: three DCT->Dire container
|
||||
* bookings + one Dire->GMP container booking are allocated, but the
|
||||
* DCT->GMP 695T bulk booking is never selected even though PW2 and NW5
|
||||
* wagons are free.
|
||||
*
|
||||
* Runs the REAL planner against the REAL 42 NW5 + 10 PW2 consist, in several
|
||||
* booking orders (= different window cycles / arrival orders).
|
||||
*
|
||||
* npx ts-node -T scripts/run-s45-scenario.ts
|
||||
*/
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import {
|
||||
planWagonsWithStock,
|
||||
type AllowedWagonTypeMap,
|
||||
type WagonStock,
|
||||
} from '../src/modules/train-scheduling/wagon-plan-flex.util';
|
||||
import { validateWagonCargoExclusivity } from '../src/modules/train-scheduling/utils/wagon-plan.util';
|
||||
import type { Booking } from '../src/modules/bookings/entities/booking.entity';
|
||||
import type { WagonType } from '../src/modules/wagon-types/entities/wagon-type.entity';
|
||||
|
||||
const YARD = {
|
||||
DCT: 'fc558b95-da28-4fc3-8348-311a290c34ae',
|
||||
DIRE: 'f7b1686f-d43e-42aa-bc6a-d3d5849296c9',
|
||||
GMP: '61ae1e66-c229-4dcd-9851-b2b9424f3a95',
|
||||
};
|
||||
const STOPS = [YARD.DCT, YARD.DIRE, YARD.GMP];
|
||||
const NW5 = '8f717b09-eec1-46ad-be3d-2dc0a56e55e7';
|
||||
const PW2 = '8eec3a7d-8482-4397-96b6-59a028210722';
|
||||
const TRAIN_ID = '56eec969-55ec-4bc5-aad6-f4b7b6d17292';
|
||||
|
||||
const ds = new DataSource({
|
||||
type: 'postgres',
|
||||
host: process.env.DB_HOST || '10.18.7.207',
|
||||
port: Number(process.env.DB_PORT || 5432),
|
||||
database: process.env.DB_NAME || 'edr_dev',
|
||||
username: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'dcba@1234',
|
||||
});
|
||||
|
||||
async function loadConfig(): Promise<{
|
||||
allowed: AllowedWagonTypeMap;
|
||||
codes: Map<string, string>;
|
||||
}> {
|
||||
const types: WagonType[] = await ds.query(
|
||||
`SELECT id, code, name, capacity_tons AS "capacityTons",
|
||||
length_meters AS "lengthMeters", tare_weight_tons AS "tareWeightTons",
|
||||
supports_container AS "supportsContainer", is_active AS "isActive"
|
||||
FROM freight.wagon_types WHERE is_active IS NOT FALSE`,
|
||||
);
|
||||
const byId = new Map(types.map((t) => [t.id, t]));
|
||||
const rows = async (sql: string) => {
|
||||
const r: Array<{ typeId: string; wagonTypeId: string }> = await ds.query(sql);
|
||||
const map = new Map<string, WagonType[]>();
|
||||
for (const row of r) {
|
||||
const wt = byId.get(row.wagonTypeId);
|
||||
if (wt) map.set(row.typeId, [...(map.get(row.typeId) ?? []), wt]);
|
||||
}
|
||||
return map;
|
||||
};
|
||||
return {
|
||||
allowed: {
|
||||
byCargoTypeId: await rows(
|
||||
`SELECT cargo_type_id AS "typeId", wagon_type_id AS "wagonTypeId" FROM freight.cargo_type_wagon_types`),
|
||||
byContainerTypeId: await rows(
|
||||
`SELECT container_type_id AS "typeId", wagon_type_id AS "wagonTypeId" FROM freight.container_type_wagon_types`),
|
||||
},
|
||||
codes: new Map(types.map((t) => [t.id, t.code])),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadBookings(allowed: AllowedWagonTypeMap): Promise<Map<string, Booking>> {
|
||||
const rows = await ds.query(
|
||||
`SELECT b.id, b.reference, b.freight_type AS "freightType",
|
||||
b.cargo_total_weight_vgm AS "cargoTotalWeightVgm",
|
||||
b.bulk_total_weight_tons AS "bulkTotalWeightTons",
|
||||
b.cargo_type_id AS "cargoTypeId", b.origin_yard_id AS "originYardId",
|
||||
b.destination_yard_id AS "destinationYardId",
|
||||
b.is_government AS "isGovernment", b.priority_score AS "priorityScore",
|
||||
b.status, b.payment_status AS "paymentStatus",
|
||||
b.created_at AS "createdAt"
|
||||
FROM freight.bookings b
|
||||
WHERE b.reference LIKE 'SF45-%' AND b.deleted_at IS NULL`);
|
||||
const cargo = await ds.query(
|
||||
`SELECT id, cargo_type_name AS "cargoTypeName", code,
|
||||
unit_of_measure AS "unitOfMeasure",
|
||||
tons_per_wagon_map AS "tonsPerWagonMap",
|
||||
items_per_wagon_map AS "itemsPerWagonMap" FROM freight.cargo_types`);
|
||||
const cargoById = new Map(cargo.map((c: any) => [c.id, c]));
|
||||
const lines = await ds.query(
|
||||
`SELECT bc.id, bc.booking_id AS "bookingId", bc.container_type_id AS "containerTypeId",
|
||||
bc.quantity, bc.vgm_per_unit_tons AS "vgmPerUnitTons",
|
||||
bc.wagons_required AS "wagonsRequired", ct.code, ct.size_ft AS "sizeFt"
|
||||
FROM freight.booking_container bc
|
||||
JOIN freight.container_types ct ON ct.id = bc.container_type_id
|
||||
WHERE bc.booking_id = ANY($1) AND bc.deleted_at IS NULL`,
|
||||
[rows.map((r: any) => r.id)]);
|
||||
|
||||
return new Map(rows.map((r: any) => {
|
||||
const ct = r.cargoTypeId ? cargoById.get(r.cargoTypeId) : null;
|
||||
return [r.reference, {
|
||||
...r,
|
||||
cargoType: ct
|
||||
? { ...ct, wagonTypes: allowed.byCargoTypeId.get(r.cargoTypeId) ?? [] }
|
||||
: null,
|
||||
bookingContainers: lines.filter((l: any) => l.bookingId === r.id).map((l: any) => ({
|
||||
...l,
|
||||
containerType: { id: l.containerTypeId, code: l.code, sizeFt: l.sizeFt },
|
||||
units: [],
|
||||
})),
|
||||
} as unknown as Booking];
|
||||
}));
|
||||
}
|
||||
|
||||
/** The train's real consist, as TRAIN-mode stock. */
|
||||
async function consistStock(codes: Map<string, string>): Promise<WagonStock> {
|
||||
const rows = await ds.query(
|
||||
`SELECT wagon_type_id AS "wagonTypeId", count(*)::int AS n
|
||||
FROM freight.wagons
|
||||
WHERE train_id = $1 AND deleted_at IS NULL
|
||||
GROUP BY wagon_type_id`, [TRAIN_ID]);
|
||||
return {
|
||||
mode: 'TRAIN',
|
||||
remainingByTypeId: new Map(rows.map((r: any) => [r.wagonTypeId, r.n])),
|
||||
codesByTypeId: codes,
|
||||
};
|
||||
}
|
||||
|
||||
const planByType = (plan: any[]) => {
|
||||
const c = new Map<string, number>();
|
||||
for (const s of plan) c.set(s.wagonTypeCode, (c.get(s.wagonTypeCode) ?? 0) + 1);
|
||||
return [...c.entries()].map(([k, n]) => `${n}x ${k}`).join(', ') || 'none';
|
||||
};
|
||||
|
||||
/** Gross tons riding each corridor edge — the locomotive pull check. */
|
||||
const perEdgeGross = (plan: any[], legs: Map<string, { from: number; to: number }>) => {
|
||||
const edges = [0, 0];
|
||||
for (const slot of plan) {
|
||||
const spans = slot.allocations.map((a: any) => legs.get(a.bookingId) ?? { from: 0, to: 2 });
|
||||
const from = Math.min(...spans.map((s: any) => s.from));
|
||||
const to = Math.max(...spans.map((s: any) => s.to));
|
||||
for (let e = from; e < to; e += 1) {
|
||||
edges[e] += Number(slot.tareWeightTons) + Number(slot.assignedWeightTons);
|
||||
}
|
||||
}
|
||||
return edges.map((t) => Math.round(t));
|
||||
};
|
||||
|
||||
async function main() {
|
||||
await ds.initialize();
|
||||
const { allowed, codes } = await loadConfig();
|
||||
const byRef = await loadBookings(allowed);
|
||||
const stock = await consistStock(codes);
|
||||
|
||||
console.log('\n=== S-2026-00045 CONSIST (real) ===');
|
||||
console.table([...stock.remainingByTypeId.entries()].map(([id, n]) => ({
|
||||
wagonType: codes.get(id), wagons: n })));
|
||||
console.log('Route: DCT -> DIRE_DAWA -> GMP (2 edges) Loco pull: 3500T / 1520m\n');
|
||||
|
||||
const ORDERS: Array<{ name: string; refs: string[] }> = [
|
||||
{ name: '1. STAGING ORDER (containers first, bulk last)',
|
||||
refs: ['SF45-5', 'SF45-3', 'SF45-2', 'SF45-1', 'SF45-4'] },
|
||||
{ name: '2. BULK FIRST (bulk books earliest window)',
|
||||
refs: ['SF45-4', 'SF45-1', 'SF45-2', 'SF45-3', 'SF45-5'] },
|
||||
{ name: '3. BULK IN THE MIDDLE',
|
||||
refs: ['SF45-1', 'SF45-2', 'SF45-4', 'SF45-3', 'SF45-5'] },
|
||||
{ name: '4. SEQUENTIAL (as listed by the user)',
|
||||
refs: ['SF45-1', 'SF45-2', 'SF45-3', 'SF45-4', 'SF45-5'] },
|
||||
{ name: '5. BULK ONLY (nothing competing)',
|
||||
refs: ['SF45-4'] },
|
||||
{ name: '6. BULK + the Dire->GMP leg only',
|
||||
refs: ['SF45-5', 'SF45-4'] },
|
||||
];
|
||||
|
||||
const summary: Array<Record<string, unknown>> = [];
|
||||
// Second pass models the staging reality: the bulk booking is PAID (the
|
||||
// customer's money is already taken) while the containers are not.
|
||||
const PAID_BULK = process.env.PAID_BULK === '1';
|
||||
|
||||
for (const order of ORDERS) {
|
||||
const bookings = order.refs.map((r) => {
|
||||
const b = byRef.get(r)!;
|
||||
if (!b) return b;
|
||||
return PAID_BULK && r === 'SF45-4'
|
||||
? ({ ...b, status: 'PAID', paymentStatus: 'PAID' } as Booking)
|
||||
: b;
|
||||
}).filter(Boolean);
|
||||
const legs = new Map(bookings.map((b: any) => [
|
||||
b.id,
|
||||
{ from: STOPS.indexOf(b.originYardId), to: STOPS.indexOf(b.destinationYardId) },
|
||||
]));
|
||||
const result = planWagonsWithStock({
|
||||
bookings, allowed,
|
||||
stock: {
|
||||
...stock,
|
||||
remainingByTypeId: new Map(stock.remainingByTypeId),
|
||||
},
|
||||
legs, edgeCount: 2, stops: STOPS,
|
||||
});
|
||||
|
||||
const refOf = (id: string) =>
|
||||
bookings.find((b: any) => b.id === id)?.reference ?? id;
|
||||
const bulkFitted = result.fitting.some((b) => b.reference === 'SF45-4');
|
||||
const bulkDeferred = result.deferred.find((d) => d.reference === 'SF45-4');
|
||||
const gross = perEdgeGross(result.plan, legs);
|
||||
const excl = validateWagonCargoExclusivity(result.plan, legs, 2);
|
||||
|
||||
console.log(`\n──────── ${order.name} ────────`);
|
||||
console.log(`order: ${order.refs.join(' -> ')}`);
|
||||
console.log(`plan: ${result.plan.length} wagons — ${planByType(result.plan)}`);
|
||||
console.log(`gross per edge: DCT->Dire ${gross[0]}T | Dire->GMP ${gross[1]}T (limit 3500T)`);
|
||||
console.log(`fitting: ${result.fitting.map((b) => b.reference).join(', ') || 'none'}`);
|
||||
for (const d of result.deferred) console.log(`DEFERRED: ${d.reference} — ${d.reason}`);
|
||||
if (excl.length) console.log(`EXCLUSIVITY VIOLATION: ${excl.join('; ')}`);
|
||||
const bulkSlots = result.plan.filter((s) =>
|
||||
s.allocations.some((a) => refOf(a.bookingId) === 'SF45-4'));
|
||||
if (bulkSlots.length) {
|
||||
console.log(` bulk seated on: ${planByType(bulkSlots)}`);
|
||||
}
|
||||
|
||||
summary.push({
|
||||
order: order.name.slice(0, 34),
|
||||
wagons: result.plan.length,
|
||||
'bulk SF45-4': bulkFitted ? 'SELECTED' : 'NOT SELECTED',
|
||||
deferred: result.deferred.map((d) => d.reference).join(',') || '-',
|
||||
reason: bulkDeferred ? bulkDeferred.reason.slice(0, 60) : '-',
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n\n================ SUMMARY ================');
|
||||
console.table(summary);
|
||||
await ds.destroy();
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
362
apps/edr-freight-api/scripts/run-wagon-gate-tests.ts
Normal file
362
apps/edr-freight-api/scripts/run-wagon-gate-tests.ts
Normal 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);
|
||||
});
|
||||
96
apps/edr-freight-api/scripts/seed-s45-scenario.cjs
Normal file
96
apps/edr-freight-api/scripts/seed-s45-scenario.cjs
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Seeds the S-2026-00045 reported scenario into edr_dev (idempotent).
|
||||
* References SF45-* ("Scenario Fortyfive"). Rows are kept as evidence.
|
||||
*
|
||||
* SELECT * FROM freight.bookings WHERE reference LIKE 'SF45-%';
|
||||
*/
|
||||
const { Client } = require('pg');
|
||||
|
||||
const YARD = {
|
||||
DCT: 'fc558b95-da28-4fc3-8348-311a290c34ae',
|
||||
DIRE: 'f7b1686f-d43e-42aa-bc6a-d3d5849296c9',
|
||||
GMP: '61ae1e66-c229-4dcd-9851-b2b9424f3a95', // KALITY = GMP (Gelan)
|
||||
};
|
||||
const PERISHABLE = 'a5991d3a-d690-4b7e-98fd-ea3333aa16e7'; // NW5 30T / PW2 20T
|
||||
const FT40 = '349072e7-8a90-4c03-b682-08976abfd7e8';
|
||||
const COMPANY = '300d5510-e3a5-4858-bc28-6e3beda8ca80';
|
||||
|
||||
const BOOKINGS = [
|
||||
{ ref: 'SF45-1', type: 'CONTAINER', from: 'DCT', to: 'DIRE', qty: 9,
|
||||
note: 'S45: DCT->Dire 9x40ft' },
|
||||
{ ref: 'SF45-2', type: 'CONTAINER', from: 'DCT', to: 'DIRE', qty: 19,
|
||||
note: 'S45: DCT->Dire 19x40ft' },
|
||||
{ ref: 'SF45-3', type: 'CONTAINER', from: 'DCT', to: 'DIRE', qty: 19,
|
||||
note: 'S45: DCT->Dire 19x40ft (second)' },
|
||||
{ ref: 'SF45-4', type: 'BULK', from: 'DCT', to: 'GMP', tons: 695,
|
||||
note: 'S45: DCT->GMP 695T Perishable - THE ONE NOT BEING SELECTED' },
|
||||
{ ref: 'SF45-5', type: 'CONTAINER', from: 'DIRE', to: 'GMP', qty: 42,
|
||||
note: 'S45: Dire->GMP 42x40ft' },
|
||||
];
|
||||
|
||||
async function main() {
|
||||
const client = new Client({
|
||||
host: process.env.DB_HOST || '10.18.7.207',
|
||||
port: Number(process.env.DB_PORT || 5432),
|
||||
database: process.env.DB_NAME || 'edr_dev',
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'dcba@1234',
|
||||
});
|
||||
await client.connect();
|
||||
const out = [];
|
||||
|
||||
for (const b of BOOKINGS) {
|
||||
const isBulk = b.type === 'BULK';
|
||||
const vgmPerUnit = 26;
|
||||
const totalVgm = isBulk ? b.tons : b.qty * vgmPerUnit;
|
||||
|
||||
const existing = await client.query(
|
||||
`SELECT id FROM freight.bookings WHERE reference = $1`, [b.ref]);
|
||||
let id;
|
||||
if (existing.rows.length) {
|
||||
id = existing.rows[0].id;
|
||||
await client.query(
|
||||
`UPDATE freight.bookings
|
||||
SET cargo_total_weight_vgm=$2, cargo_type_id=$3, origin_yard_id=$4,
|
||||
destination_yard_id=$5, freight_type=$6, trade_direction='IMPORT',
|
||||
status='CLEARANCE_READY', payment_status='PENDING',
|
||||
scheduling_status='ELIGIBLE', train_schedule_id=NULL,
|
||||
cargo_free_text=$7, updated_at=now()
|
||||
WHERE id=$1`,
|
||||
[id, totalVgm, isBulk ? PERISHABLE : null, YARD[b.from], YARD[b.to],
|
||||
b.type, b.note]);
|
||||
} else {
|
||||
const res = await client.query(
|
||||
`INSERT INTO freight.bookings (
|
||||
reference, status, total_amount, payment_status, contract_type,
|
||||
trade_direction, equipment_return, cargo_total_weight_vgm, is_hazardous,
|
||||
payment_currency, version_number, priority_score, origin_yard_id,
|
||||
destination_yard_id, cargo_type_id, cargo_free_text, freight_type,
|
||||
scheduling_status, is_government, customs_clearing_enabled, is_reefer,
|
||||
booking_type, is_split, company_id, created_at, updated_at
|
||||
) VALUES ($1,'CLEARANCE_READY',0,'PENDING','NEW','IMPORT','WITHOUT_RETURN',
|
||||
$2,false,'ETB',1,0,$3,$4,$5,$6,$7,'ELIGIBLE',false,false,false,
|
||||
'ONE_TIME',false,$8, now(), now())
|
||||
RETURNING id`,
|
||||
[b.ref, totalVgm, YARD[b.from], YARD[b.to],
|
||||
isBulk ? PERISHABLE : null, b.note, b.type, COMPANY]);
|
||||
id = res.rows[0].id;
|
||||
}
|
||||
|
||||
await client.query(`DELETE FROM freight.booking_container WHERE booking_id=$1`, [id]);
|
||||
if (!isBulk) {
|
||||
await client.query(
|
||||
`INSERT INTO freight.booking_container
|
||||
(booking_id, container_type_id, quantity, vgm_per_unit_tons,
|
||||
total_vgm_tons, wagons_required, container_size, created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,'40ft', now(), now())`,
|
||||
[id, FT40, b.qty, vgmPerUnit, b.qty * vgmPerUnit, b.qty]);
|
||||
}
|
||||
out.push({ ref: b.ref, id, note: b.note });
|
||||
}
|
||||
|
||||
console.table(out);
|
||||
await client.end();
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e.message); process.exit(1); });
|
||||
142
apps/edr-freight-api/scripts/seed-wagon-gate-testcases.cjs
Normal file
142
apps/edr-freight-api/scripts/seed-wagon-gate-testcases.cjs
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
275
apps/edr-freight-api/src/config/mor-location.resolver.spec.ts
Normal file
275
apps/edr-freight-api/src/config/mor-location.resolver.spec.ts
Normal 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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
255
apps/edr-freight-api/src/config/mor-location.resolver.ts
Normal file
255
apps/edr-freight-api/src/config/mor-location.resolver.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
1181
apps/edr-freight-api/src/config/mor-locations.data.ts
Normal file
1181
apps/edr-freight-api/src/config/mor-locations.data.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
@@ -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"`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Loading and unloading times, on the stop that already records the train
|
||||
* standing at a station.
|
||||
*
|
||||
* The OCC report publishes, per train, "total loading and unloading time" and
|
||||
* the "other activity" left over from the station stay. Nothing recorded when
|
||||
* handling started or ended — the July 2026 seed had to write the figure into
|
||||
* a checkpoint's note — so the staying-time report could only ever publish the
|
||||
* whole stay.
|
||||
*
|
||||
* These four go on `train_checkpoint_events` rather than a table of their own:
|
||||
* a stop is already one row there, keyed (schedule, sequence_no), and the
|
||||
* arrival row is the one the staying-time report builds a stay from. All four
|
||||
* are nullable — a stop where nobody logged the handling still reports its
|
||||
* staying time, with the handling columns empty rather than zero.
|
||||
*/
|
||||
export class CheckpointHandlingTimes3690000000000 implements MigrationInterface {
|
||||
name = "CheckpointHandlingTimes3690000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_checkpoint_events
|
||||
ADD COLUMN IF NOT EXISTS unloading_started_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS unloading_completed_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS loading_started_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS loading_completed_at timestamptz;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_checkpoint_events
|
||||
DROP COLUMN IF EXISTS unloading_started_at,
|
||||
DROP COLUMN IF EXISTS unloading_completed_at,
|
||||
DROP COLUMN IF EXISTS loading_started_at,
|
||||
DROP COLUMN IF EXISTS loading_completed_at;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Standard loading-and-unloading time, so the handling figure can be reported
|
||||
* the way the OCC scorecard reports it — hours against a target, with a rate.
|
||||
*
|
||||
* Nullable with NO default, unlike every other column in this table. The
|
||||
* reporting spec publishes standards for a station stay (10h / 13h) and for a
|
||||
* turn-around cycle (65 / 88 / 96) but none for handling, so there is no
|
||||
* honest figure to seed. Until a planner enters one in Operating standards the
|
||||
* rate reads empty rather than judging trains against an invented number.
|
||||
*/
|
||||
export class HandlingStandards3700000000000 implements MigrationInterface {
|
||||
name = "HandlingStandards3700000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.operations_standards
|
||||
ADD COLUMN IF NOT EXISTS handling_standard_hours_container numeric(6,2),
|
||||
ADD COLUMN IF NOT EXISTS handling_standard_hours_bulk numeric(6,2);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.operations_standards
|
||||
DROP COLUMN IF EXISTS handling_standard_hours_container,
|
||||
DROP COLUMN IF EXISTS handling_standard_hours_bulk;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
@@ -30,11 +32,16 @@ import {
|
||||
InvoiceDocumentService,
|
||||
pngDataUrl,
|
||||
} from "./documents/invoice-document.service";
|
||||
import { INVOICE_SORT_COLUMNS } from "./dto/filter-invoice.dto";
|
||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
|
||||
import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||
import { nextDailyInvoiceNumber } from "./invoice-numbering.util";
|
||||
import { applySettlement, round2 } from "./invoice-settlement.util";
|
||||
import {
|
||||
applySettlement,
|
||||
invoicePaymentMethodExpr,
|
||||
round2,
|
||||
} from "./invoice-settlement.util";
|
||||
import { InvoiceRepository } from "./invoice.repository";
|
||||
|
||||
/** Options forwarded to the payment gateway when settling an invoice. */
|
||||
@@ -97,6 +104,40 @@ export interface RecordPaymentInput {
|
||||
}
|
||||
|
||||
/** Default invoice payment-term window, in days, used to compute `dueAt`. */
|
||||
/**
|
||||
* Every dimension the backoffice invoice list narrows by. `findAllPaginated`
|
||||
* and `collectedSummary` share it so the summary card can never total a
|
||||
* different set of invoices than the table below it shows.
|
||||
*/
|
||||
export interface InvoiceListFilters {
|
||||
companyId?: string;
|
||||
status?: Freight.InvoiceStatus;
|
||||
statuses?: Freight.InvoiceStatus[];
|
||||
sources?: string[];
|
||||
eimsStatuses?: string[];
|
||||
/** Settled payment method, normalised UPPER_SNAKE — see `invoicePaymentMethodExpr`. */
|
||||
paymentMethods?: string[];
|
||||
currency?: string;
|
||||
search?: string;
|
||||
issuedFrom?: string;
|
||||
issuedTo?: string;
|
||||
dueFrom?: string;
|
||||
dueTo?: string;
|
||||
minAmount?: number;
|
||||
maxAmount?: number;
|
||||
hasBalance?: boolean;
|
||||
overdue?: boolean;
|
||||
/** Per-user trade-direction scope, applied via the source booking. */
|
||||
tradeDirections?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The list/summary query builders both alias the invoice as `invoice` and the
|
||||
* joined gateway payment as `payment`; TypeORM rewrites those alias.property
|
||||
* references into real quoted columns.
|
||||
*/
|
||||
const PAYMENT_METHOD_EXPR = invoicePaymentMethodExpr("invoice", "payment");
|
||||
|
||||
const DEFAULT_DUE_DAYS = 14;
|
||||
|
||||
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
|
||||
@@ -243,12 +284,7 @@ export class BillingService {
|
||||
/** Same list filters `findAllPaginated` and `collectedSummary` both narrow by. */
|
||||
private applyInvoiceFilters(
|
||||
qb: SelectQueryBuilder<Invoice>,
|
||||
filter: {
|
||||
companyId?: string;
|
||||
status?: Freight.InvoiceStatus;
|
||||
search?: string;
|
||||
tradeDirections?: string[];
|
||||
},
|
||||
filter: InvoiceListFilters,
|
||||
) {
|
||||
if (filter.companyId) {
|
||||
qb.andWhere("invoice.companyId = :companyId", {
|
||||
@@ -258,21 +294,83 @@ export class BillingService {
|
||||
if (filter.status) {
|
||||
qb.andWhere("invoice.status = :status", { status: filter.status });
|
||||
}
|
||||
if (filter.statuses?.length) {
|
||||
qb.andWhere("invoice.status IN (:...statuses)", {
|
||||
statuses: filter.statuses,
|
||||
});
|
||||
}
|
||||
if (filter.sources?.length) {
|
||||
qb.andWhere("invoice.source IN (:...sources)", { sources: filter.sources });
|
||||
}
|
||||
if (filter.eimsStatuses?.length) {
|
||||
qb.andWhere("invoice.eimsStatus IN (:...eimsStatuses)", {
|
||||
eimsStatuses: filter.eimsStatuses,
|
||||
});
|
||||
}
|
||||
if (filter.paymentMethods?.length) {
|
||||
// Requires the `payment` alias to be joined by the caller — both call
|
||||
// sites do, unconditionally, so this can never reference a missing alias.
|
||||
qb.andWhere(`${PAYMENT_METHOD_EXPR} IN (:...paymentMethods)`, {
|
||||
paymentMethods: filter.paymentMethods,
|
||||
});
|
||||
}
|
||||
if (filter.currency) {
|
||||
// Stored casing has drifted ("usd" rows exist) — compare normalised.
|
||||
qb.andWhere("UPPER(invoice.currency) = :currency", {
|
||||
currency: filter.currency.toUpperCase(),
|
||||
});
|
||||
}
|
||||
if (filter.issuedFrom) {
|
||||
qb.andWhere("invoice.issuedAt >= :issuedFrom", {
|
||||
issuedFrom: filter.issuedFrom,
|
||||
});
|
||||
}
|
||||
if (filter.issuedTo) {
|
||||
qb.andWhere("invoice.issuedAt <= :issuedTo", { issuedTo: filter.issuedTo });
|
||||
}
|
||||
if (filter.dueFrom) {
|
||||
qb.andWhere("invoice.dueAt >= :dueFrom", { dueFrom: filter.dueFrom });
|
||||
}
|
||||
if (filter.dueTo) {
|
||||
qb.andWhere("invoice.dueAt <= :dueTo", { dueTo: filter.dueTo });
|
||||
}
|
||||
if (filter.minAmount !== undefined) {
|
||||
qb.andWhere("invoice.totalAmount >= :minAmount", {
|
||||
minAmount: filter.minAmount,
|
||||
});
|
||||
}
|
||||
if (filter.maxAmount !== undefined) {
|
||||
qb.andWhere("invoice.totalAmount <= :maxAmount", {
|
||||
maxAmount: filter.maxAmount,
|
||||
});
|
||||
}
|
||||
if (filter.hasBalance) {
|
||||
qb.andWhere("invoice.balanceAmount > 0");
|
||||
}
|
||||
if (filter.overdue) {
|
||||
// Computed, not `status = OVERDUE`: nothing sweeps PENDING rows into
|
||||
// that status, so reading the column alone under-reports the arrears.
|
||||
qb.andWhere("invoice.balanceAmount > 0 AND invoice.dueAt < now()");
|
||||
}
|
||||
if (filter.search) {
|
||||
// Searches what the row actually shows: its number, who it bills, and
|
||||
// the source record behind it (booking reference, GRN, shipping line).
|
||||
// Searches what the row actually shows: its number, who it bills, the
|
||||
// source record behind it (booking reference, PNR, GRN, shipping line)
|
||||
// and the payment references a customer or a provider support desk would
|
||||
// quote back — the gateway transaction id and our merchant order id.
|
||||
// The raw `sourceId` stays matchable so a pasted UUID still resolves.
|
||||
// Requires the `company` alias — every caller of this joins it.
|
||||
// Requires the `company` and `payment` aliases — every caller joins both.
|
||||
qb.andWhere(
|
||||
`(invoice.invoiceNumber ILIKE :search
|
||||
OR invoice.sourceId ILIKE :search
|
||||
OR company.name ILIKE :search
|
||||
OR payment.transactionId ILIKE :search
|
||||
OR payment.merchantOrderId ILIKE :search
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM freight.bookings b
|
||||
LEFT JOIN freight.warehouse_inventory wi ON wi.booking_id = b.id
|
||||
LEFT JOIN freight.first_mile fm ON fm.booking_id = b.id
|
||||
LEFT JOIN freight.last_mile lm ON lm.booking_id = b.id
|
||||
WHERE b.reference ILIKE :search
|
||||
WHERE (b.reference ILIKE :search OR b.pnr_code ILIKE :search)
|
||||
AND (b.id::text = invoice.source_id
|
||||
OR wi.id::text = invoice.source_id
|
||||
OR fm.id::text = invoice.source_id
|
||||
@@ -299,14 +397,11 @@ export class BillingService {
|
||||
}
|
||||
|
||||
async findAllPaginated(
|
||||
filter: {
|
||||
companyId?: string;
|
||||
status?: Freight.InvoiceStatus;
|
||||
search?: string;
|
||||
filter: InvoiceListFilters & {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
/** Per-user trade-direction scope, applied via the source booking. */
|
||||
tradeDirections?: string[];
|
||||
sortBy?: string;
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
} = {},
|
||||
): Promise<{ items: InvoiceListRow[]; total: number }> {
|
||||
const page = filter.page && filter.page > 0 ? filter.page : 1;
|
||||
@@ -317,7 +412,17 @@ export class BillingService {
|
||||
.getRepository(Invoice)
|
||||
.createQueryBuilder("invoice")
|
||||
.leftJoinAndSelect("invoice.company", "company")
|
||||
.orderBy("invoice.issuedAt", "DESC")
|
||||
// The gateway payment behind the invoice: the settled method and the
|
||||
// provider's transaction reference both live on it, and nowhere else.
|
||||
.leftJoinAndSelect("invoice.payment", "payment")
|
||||
// sortBy is whitelisted through INVOICE_SORT_COLUMNS, never interpolated
|
||||
// raw. The id tiebreaker keeps paging stable when the sort column ties
|
||||
// (issuedAt is null on every DRAFT row).
|
||||
.orderBy(
|
||||
INVOICE_SORT_COLUMNS[filter.sortBy ?? ""] ?? "invoice.issuedAt",
|
||||
filter.sortOrder ?? "DESC",
|
||||
)
|
||||
.addOrderBy("invoice.id", "ASC")
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize);
|
||||
|
||||
@@ -458,12 +563,7 @@ export class BillingService {
|
||||
* visible page.
|
||||
*/
|
||||
async collectedSummary(
|
||||
filter: {
|
||||
companyId?: string;
|
||||
status?: Freight.InvoiceStatus;
|
||||
search?: string;
|
||||
tradeDirections?: string[];
|
||||
} = {},
|
||||
filter: InvoiceListFilters = {},
|
||||
): Promise<Record<string, number>> {
|
||||
const qb = this.dataSource
|
||||
.getRepository(Invoice)
|
||||
@@ -471,6 +571,7 @@ export class BillingService {
|
||||
// Joined, not selected: `applyInvoiceFilters` searches the customer name,
|
||||
// so the alias has to exist even though the summary only sums money.
|
||||
.leftJoin("invoice.company", "company")
|
||||
.leftJoin("invoice.payment", "payment")
|
||||
.select("invoice.currency", "currency")
|
||||
.addSelect("SUM(invoice.paidAmount)", "collected")
|
||||
.groupBy("invoice.currency");
|
||||
@@ -638,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"],
|
||||
@@ -677,7 +785,7 @@ export class BillingService {
|
||||
/** Invoice header plus its line items. */
|
||||
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
const invoice = await this.invoices.findById(id, {
|
||||
relations: { company: true, companyProfile: true },
|
||||
relations: { company: true, companyProfile: true, payment: true },
|
||||
});
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
||||
const [hydrated] = await this.attachShippingLineCompanies([invoice]);
|
||||
@@ -1958,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.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { plainToInstance } from "class-transformer";
|
||||
import { validateSync } from "class-validator";
|
||||
|
||||
import { FilterInvoiceDto } from "./filter-invoice.dto";
|
||||
|
||||
/**
|
||||
* The list endpoint runs under `forbidNonWhitelisted`, so every param the
|
||||
* backoffice filter bar sends has to survive transform + validation here or
|
||||
* the whole request 400s. The CSV filters are the fragile part: they arrive as
|
||||
* one string and must come out as a validated array.
|
||||
*/
|
||||
const parse = (query: Record<string, string>) => {
|
||||
const dto = plainToInstance(FilterInvoiceDto, query);
|
||||
return { dto, errors: validateSync(dto).map((e) => e.property) };
|
||||
};
|
||||
|
||||
describe("FilterInvoiceDto", () => {
|
||||
it("accepts the full filter-bar query and splits the CSV filters", () => {
|
||||
const { dto, errors } = parse({
|
||||
page: "2",
|
||||
pageSize: "10",
|
||||
search: "INV-2026",
|
||||
statuses: "PENDING,OVERDUE",
|
||||
sources: "booking,warehouse",
|
||||
eimsStatuses: "NOT_SUBMITTED",
|
||||
currency: "etb",
|
||||
issuedFrom: "2026-08-01T00:00:00.000Z",
|
||||
issuedTo: "2026-08-20T20:59:59.999Z",
|
||||
dueFrom: "2026-08-01T00:00:00.000Z",
|
||||
dueTo: "2026-09-01T20:59:59.999Z",
|
||||
minAmount: "100",
|
||||
maxAmount: "5000",
|
||||
hasBalance: "true",
|
||||
overdue: "false",
|
||||
sortBy: "balanceAmount",
|
||||
sortOrder: "asc",
|
||||
});
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(dto.statuses).toEqual(["PENDING", "OVERDUE"]);
|
||||
expect(dto.sources).toEqual(["booking", "warehouse"]);
|
||||
expect(dto.currency).toBe("ETB");
|
||||
expect(dto.minAmount).toBe(100);
|
||||
expect(dto.hasBalance).toBe(true);
|
||||
expect(dto.overdue).toBe(false);
|
||||
expect(dto.sortOrder).toBe("ASC");
|
||||
});
|
||||
|
||||
it("rejects a value outside the enum and an unsortable column", () => {
|
||||
expect(parse({ statuses: "PENDING,NOT_A_STATUS" }).errors).toEqual(["statuses"]);
|
||||
expect(parse({ sortBy: "eimsIrn" }).errors).toEqual(["sortBy"]);
|
||||
});
|
||||
});
|
||||
@@ -2,14 +2,44 @@ import { Freight } from "@edr/types";
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Min,
|
||||
} from "class-validator";
|
||||
|
||||
import { EimsInvoiceStatus } from "../../eims/eims-registration.types";
|
||||
import { INVOICE_PAYMENT_METHODS } from "../invoice-settlement.util";
|
||||
|
||||
/** Columns the invoice list may be ordered by -> their query-builder expression. */
|
||||
export const INVOICE_SORT_COLUMNS: Record<string, string> = {
|
||||
issuedAt: "invoice.issuedAt",
|
||||
dueAt: "invoice.dueAt",
|
||||
createdAt: "invoice.createdAt",
|
||||
totalAmount: "invoice.totalAmount",
|
||||
balanceAmount: "invoice.balanceAmount",
|
||||
invoiceNumber: "invoice.invoiceNumber",
|
||||
};
|
||||
|
||||
/** `?statuses=A,B` -> `["A","B"]`. A bare value stays a one-element list. */
|
||||
const csv = ({ value }: { value: unknown }) =>
|
||||
typeof value === "string"
|
||||
? value
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean)
|
||||
: value;
|
||||
|
||||
const bool = ({ value }: { value: unknown }) => value === "true" || value === true;
|
||||
|
||||
const num = ({ value }: { value: unknown }) => Number(value);
|
||||
|
||||
export class FilterInvoiceDto {
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@@ -40,10 +70,110 @@ export class FilterInvoiceDto {
|
||||
@IsIn(Object.values(Freight.InvoiceStatus))
|
||||
status?: Freight.InvoiceStatus;
|
||||
|
||||
/** Manual-payments worklist only: restrict to one currency. */
|
||||
/**
|
||||
* Multi-select status (`?statuses=PENDING,OVERDUE`). ANDed with `status`
|
||||
* when both are sent, so the single-status worklists keep their meaning.
|
||||
*/
|
||||
@ApiPropertyOptional({ isArray: true, enum: Freight.InvoiceStatus })
|
||||
@IsOptional()
|
||||
@Transform(csv)
|
||||
@IsArray()
|
||||
@IsIn(Object.values(Freight.InvoiceStatus), { each: true })
|
||||
statuses?: Freight.InvoiceStatus[];
|
||||
|
||||
/** Originating subsystem (`booking`, `warehouse`, `shipping_line_credit`, …). */
|
||||
@ApiPropertyOptional({ isArray: true, enum: Freight.InvoiceSource })
|
||||
@IsOptional()
|
||||
@Transform(csv)
|
||||
@IsArray()
|
||||
@IsIn(Object.values(Freight.InvoiceSource), { each: true })
|
||||
sources?: Freight.InvoiceSource[];
|
||||
|
||||
/** MoR filing state — Finance's "what still needs registering" cut. */
|
||||
@ApiPropertyOptional({ isArray: true, enum: EimsInvoiceStatus })
|
||||
@IsOptional()
|
||||
@Transform(csv)
|
||||
@IsArray()
|
||||
@IsIn(Object.values(EimsInvoiceStatus), { each: true })
|
||||
eimsStatuses?: EimsInvoiceStatus[];
|
||||
|
||||
/**
|
||||
* Settled payment method (`?paymentMethods=CBE_BILL,BANK_TRANSFER`). Values are
|
||||
* the normalised UPPER_SNAKE vocabulary of `invoicePaymentMethodExpr`. Not
|
||||
* validated against a fixed list — the manual pay endpoint takes a free-form
|
||||
* method, so an `IsIn` here would silently drop a real value.
|
||||
*/
|
||||
@ApiPropertyOptional({ isArray: true, enum: INVOICE_PAYMENT_METHODS })
|
||||
@IsOptional()
|
||||
@Transform(csv)
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
paymentMethods?: string[];
|
||||
|
||||
/** Manual-payments worklist and the invoice list: restrict to one currency. */
|
||||
@ApiPropertyOptional({ enum: ["USD", "ETB"] })
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
|
||||
@IsIn(["USD", "ETB"])
|
||||
currency?: "USD" | "ETB";
|
||||
|
||||
@ApiPropertyOptional({ description: "Issued at or after this instant (ISO)." })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
issuedFrom?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Issued at or before this instant (ISO)." })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
issuedTo?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Due at or after this instant (ISO)." })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dueFrom?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Due at or before this instant (ISO)." })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dueTo?: string;
|
||||
|
||||
/** Total amount bounds, in the invoice's own currency — pair with `currency`. */
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@Transform(num)
|
||||
@IsNumber()
|
||||
minAmount?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@Transform(num)
|
||||
@IsNumber()
|
||||
maxAmount?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: "Only invoices with an outstanding balance." })
|
||||
@IsOptional()
|
||||
@Transform(bool)
|
||||
@IsBoolean()
|
||||
hasBalance?: boolean;
|
||||
|
||||
/**
|
||||
* Outstanding AND past its due date, computed rather than read off `status`:
|
||||
* nothing sweeps PENDING rows into OVERDUE, so the status alone under-reports.
|
||||
*/
|
||||
@ApiPropertyOptional({ description: "Only invoices outstanding past their due date." })
|
||||
@IsOptional()
|
||||
@Transform(bool)
|
||||
@IsBoolean()
|
||||
overdue?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ enum: Object.keys(INVOICE_SORT_COLUMNS), default: "issuedAt" })
|
||||
@IsOptional()
|
||||
@IsIn(Object.keys(INVOICE_SORT_COLUMNS))
|
||||
sortBy?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "DESC" })
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
|
||||
@IsIn(["ASC", "DESC"])
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -90,26 +86,24 @@ describe("toEimsInvoice", () => {
|
||||
expect(doc.SellerDetails).toBe(seller);
|
||||
});
|
||||
|
||||
it("maps the buyer from the company row and leaves unmodelled fields null", () => {
|
||||
it("maps the buyer from the company row and omits Id fields for a TIN-identified buyer", () => {
|
||||
const doc = toEimsInvoice(invoice(), seller, context());
|
||||
|
||||
// MoR rule 7004 rejects an explicit IdType/IdNumber null — the keys must be absent.
|
||||
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,
|
||||
IdType: null,
|
||||
Tin: "0999930000",
|
||||
LegalName: "ABC Trading PLC",
|
||||
Phone: "0912345678",
|
||||
Region: "13",
|
||||
Region: "6",
|
||||
Zone: "SHA",
|
||||
Kebele: "03",
|
||||
VatNumber: "123475885858",
|
||||
Wereda: "574",
|
||||
Wereda: "190",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -284,108 +278,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", () => {
|
||||
|
||||
@@ -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. */
|
||||
@@ -34,8 +35,9 @@ export interface EimsBuyerDetails {
|
||||
City: string | null;
|
||||
Email: string | null;
|
||||
HouseNumber: string | null;
|
||||
IdNumber: string | null;
|
||||
IdType: string | null;
|
||||
/** Omitted entirely for a TIN-identified buyer — MoR rule 7004 rejects an explicit null. */
|
||||
IdNumber?: string;
|
||||
IdType?: string;
|
||||
Tin: string;
|
||||
LegalName: string;
|
||||
Phone: string | null;
|
||||
@@ -235,35 +237,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 +255,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 +283,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,46 +404,24 @@ 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,
|
||||
IdType: context.buyerIdType ?? null,
|
||||
...(context.buyerIdNumber != null ? { IdNumber: context.buyerIdNumber } : {}),
|
||||
...(context.buyerIdType != null ? { IdType: context.buyerIdType } : {}),
|
||||
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,
|
||||
|
||||
@@ -34,3 +34,43 @@ export function applySettlement(
|
||||
const balanceAmount = Math.max(0, round2(total - paidAmount));
|
||||
return { paidAmount, balanceAmount, fullyPaid: paidAmount >= total };
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL for an invoice's settled payment method, normalised to one vocabulary.
|
||||
*
|
||||
* Two sources have to be merged: gateway settlements carry the real provider on
|
||||
* the linked `freight.payments` row (`cbe-bill`, `telebirr`, …) while the
|
||||
* invoice's own `payments` ledger only records a flat `"GATEWAY"`; manual
|
||||
* settlements have no payments row at all and the ledger is the ONLY source
|
||||
* (`BANK_TRANSFER`, `OFFLINE`, or whatever `PayInvoiceDto.method` carried).
|
||||
* So: provider first, newest ledger entry as the fallback.
|
||||
*
|
||||
* `-> -1` is the last ledger element — the ledger is appended newest-last.
|
||||
* `::text` is not cosmetic: `payments.method` is a real Postgres enum, and
|
||||
* COALESCE against a text fallback fails without the cast.
|
||||
*
|
||||
* Normalised UPPER_SNAKE so `cbe-bill` and a hand-typed `CBE_BILL` are one
|
||||
* value on screen, in the filter and in the export.
|
||||
*/
|
||||
export const invoicePaymentMethodExpr = (invoice: string, payment: string): string =>
|
||||
`UPPER(REPLACE(COALESCE(${payment}.method::text, ${invoice}.payments -> -1 ->> 'method'), '-', '_'))`;
|
||||
|
||||
/**
|
||||
* The methods the filter offers. Not exhaustive by construction — the manual
|
||||
* pay endpoint takes a free-form `method` string — so nothing validates against
|
||||
* this list; it is the pick-list, not a constraint.
|
||||
*/
|
||||
export const INVOICE_PAYMENT_METHODS = [
|
||||
"TELEBIRR",
|
||||
"CBE_BIRR",
|
||||
"CBE_BILL",
|
||||
"EBIRR",
|
||||
"WAAFI",
|
||||
"CARD",
|
||||
"DMONEY",
|
||||
"CAC_BANK",
|
||||
"BANK_TRANSFER",
|
||||
"OFFLINE",
|
||||
/** Settled at a gateway whose provider row is no longer linked. */
|
||||
"GATEWAY",
|
||||
] as const;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,46 +356,101 @@ 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);
|
||||
@@ -311,6 +464,10 @@ export class BookingClearanceChargeService {
|
||||
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.');
|
||||
}
|
||||
|
||||
// Save the row first so its id can key the document. A booking may carry
|
||||
// several miscellaneous charges, and `upsertByCode` retires whatever sits
|
||||
@@ -323,6 +480,7 @@ export class BookingClearanceChargeService {
|
||||
status: 'BILLED',
|
||||
amount: input.amount.toFixed(2),
|
||||
currency: input.currency.trim().toUpperCase(),
|
||||
description,
|
||||
uploadedByStaffId: staffId,
|
||||
uploadedAt: new Date(),
|
||||
billedByStaffId: staffId,
|
||||
@@ -342,11 +500,12 @@ export class BookingClearanceChargeService {
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -421,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.`;
|
||||
|
||||
@@ -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()];
|
||||
}
|
||||
}
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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';
|
||||
@@ -25,9 +26,18 @@ import { Booking } from './entities/booking.entity';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
import { ContainerValidationService } from './container-validation.service';
|
||||
|
||||
/**
|
||||
* One physical container over its VGM limit. Weight limits are per container,
|
||||
* so an overloaded box is reported (and billed) on its own tons above the
|
||||
* limit — a lighter box on the same line never absorbs them.
|
||||
*/
|
||||
export interface OverweightLine {
|
||||
containerTypeCode: string;
|
||||
/** Container number when known, else "<code> #2" — identifies the box. */
|
||||
containerLabel: string;
|
||||
/** This container's VGM, not the line total. */
|
||||
totalVgmTons: number;
|
||||
/** The per-container limit. */
|
||||
maxAllowedTons: number;
|
||||
excessTons: number;
|
||||
}
|
||||
@@ -84,6 +94,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> {
|
||||
@@ -248,9 +259,10 @@ export class BookingPricingService {
|
||||
clearanceBlocked.push(...clearance.blocked);
|
||||
}
|
||||
|
||||
// Overweight detail for the customer: map the engine's per-line results back
|
||||
// to the booking's container lines (same order) for code + weights. maxAllowed
|
||||
// is derived from the line total minus the excess the engine computed.
|
||||
// Overweight detail for the customer: one row per over-limit CONTAINER,
|
||||
// mapped back to the booking's container lines (same order) for the code and
|
||||
// the physical container numbers. maxAllowed is the per-container limit,
|
||||
// recovered from that container's weight minus its own excess.
|
||||
const overweightLines: OverweightLine[] = [];
|
||||
const containerLines = (booking.bookingContainers ?? []).filter(
|
||||
(bc) => bc.containerTypeId != null,
|
||||
@@ -259,8 +271,6 @@ export class BookingPricingService {
|
||||
const wr = ruleResult.containerWeightResults[i];
|
||||
if (!wr?.isOverweight) continue;
|
||||
const line = containerLines[i];
|
||||
const totalVgmTons = Number(line?.totalVgmTons ?? 0);
|
||||
const excessTons = Number(wr.overweightExcessTons ?? 0);
|
||||
let code = line?.containerSize ?? '';
|
||||
if (line?.containerTypeId) {
|
||||
try {
|
||||
@@ -269,12 +279,32 @@ export class BookingPricingService {
|
||||
// fall back to the container size label
|
||||
}
|
||||
}
|
||||
overweightLines.push({
|
||||
containerTypeCode: code,
|
||||
totalVgmTons,
|
||||
maxAllowedTons: Math.max(0, totalVgmTons - excessTons),
|
||||
excessTons,
|
||||
});
|
||||
const numbers = (line?.units ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||
.map((u) => u.containerNumber);
|
||||
// Legacy weight results carry no per-unit detail (a line total only) —
|
||||
// report the line as a single row, as before.
|
||||
const units = wr.overweightUnits?.length
|
||||
? wr.overweightUnits
|
||||
: [
|
||||
{
|
||||
unitIndex: 0,
|
||||
vgmTons: Number(line?.totalVgmTons ?? 0),
|
||||
excessTons: Number(wr.overweightExcessTons ?? 0),
|
||||
},
|
||||
];
|
||||
for (const u of units) {
|
||||
overweightLines.push({
|
||||
containerTypeCode: code,
|
||||
containerLabel:
|
||||
(u.unitIndex > 0 ? numbers[u.unitIndex - 1] : null) ||
|
||||
(u.unitIndex > 0 ? `${code} #${u.unitIndex}` : code),
|
||||
totalVgmTons: u.vgmTons,
|
||||
maxAllowedTons: Math.max(0, u.vgmTons - u.excessTons),
|
||||
excessTons: u.excessTons,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -344,6 +374,13 @@ export class BookingPricingService {
|
||||
quantity: qty,
|
||||
vgmPerUnitTons: vgm,
|
||||
totalVgmTons: qty * vgm,
|
||||
// Real per-box weights when the booking recorded them: weight
|
||||
// limits are per container, so 22/18/20t is 2t over on the first
|
||||
// box even though the line total fits a 3x20t allowance.
|
||||
unitVgmTons: (bc.units ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||
.map((u) => Number(u.vgmTons ?? 0)),
|
||||
isReefer: ct.isReefer,
|
||||
// Per-container opt-ins — PER_CONTAINER surcharges bill these.
|
||||
hazardousQuantity: Number(bc.hazardousQuantity ?? 0),
|
||||
@@ -1060,9 +1097,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 +1125,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 +1161,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 +1179,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 +1196,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 +1227,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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
@@ -742,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",
|
||||
@@ -890,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_")
|
||||
|
||||
@@ -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,273 @@ 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,
|
||||
// A dead booking holds no shipment day — leaving it set lets the
|
||||
// stranded-PAID day sweep pick the booking up and resurrect it.
|
||||
scheduledDate: 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,
|
||||
// A dead booking holds no shipment day — leaving it set lets the
|
||||
// stranded-PAID day sweep pick the booking up and resurrect it.
|
||||
scheduledDate: 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 +718,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 +767,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 +789,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 +1475,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 +1502,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,
|
||||
})),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (contract–booking split), so the
|
||||
// references are batch-loaded by id — one query for the whole page.
|
||||
const { service, contractRepo } = makeService({
|
||||
approvals: {
|
||||
findQueuePage: jest.fn().mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
...PENDING,
|
||||
booking: { id: "b-1", contractId: "c-1" },
|
||||
partnerBooking: { id: "b-2", contractId: "c-2" },
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}),
|
||||
},
|
||||
contracts: [
|
||||
{ id: "c-1", reference: "CT-001" },
|
||||
{ id: "c-2", reference: "CT-002" },
|
||||
],
|
||||
});
|
||||
|
||||
const { items } = await service.queue();
|
||||
|
||||
expect(items[0].contractReference).toBe("CT-001");
|
||||
expect(items[0].partnerContractReference).toBe("CT-002");
|
||||
expect(contractRepo.find).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("leaves the contract reference null when a half has no contract", async () => {
|
||||
const { service, contractRepo } = makeService({
|
||||
approvals: {
|
||||
findQueuePage: jest.fn().mockResolvedValue({
|
||||
items: [{ ...PENDING, booking: { id: "b-1" }, partnerBooking: null }],
|
||||
total: 1,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const { items } = await service.queue();
|
||||
|
||||
expect(items[0].contractReference).toBeNull();
|
||||
expect(items[0].partnerContractReference).toBeNull();
|
||||
// Nothing to look up — no query at all.
|
||||
expect(contractRepo.find).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (contract–booking split), so the
|
||||
* references are batch-loaded by id rather than joined — one query per page,
|
||||
* not one per row.
|
||||
*/
|
||||
private async contractReferences(
|
||||
rows: ConsolidationApproval[],
|
||||
): Promise<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()}.`,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -27,11 +27,15 @@ export class PriceLineItemDto {
|
||||
currency!: string;
|
||||
}
|
||||
|
||||
/** One physical container over its per-container VGM limit. */
|
||||
export class OverweightLineDto {
|
||||
@ApiProperty()
|
||||
containerTypeCode!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@ApiProperty({ description: 'Container number, or "<code> #2" when unnumbered' })
|
||||
containerLabel!: string;
|
||||
|
||||
@ApiProperty({ description: "This container's VGM in tons" })
|
||||
totalVgmTons!: number;
|
||||
|
||||
@ApiProperty()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -9,6 +9,8 @@ export const CLEARANCE_CHARGE_STATUSES = [
|
||||
'DOC_UPLOADED',
|
||||
'BILLED',
|
||||
'SENT',
|
||||
'REJECTED',
|
||||
'ACCEPTED',
|
||||
'PAID',
|
||||
] as const;
|
||||
export type ClearanceChargeStatus = (typeof CLEARANCE_CHARGE_STATUSES)[number];
|
||||
@@ -17,9 +19,11 @@ export type ClearanceChargeStatus = (typeof CLEARANCE_CHARGE_STATUSES)[number];
|
||||
* 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 (BILLED) and issues the invoice (SENT); the billing
|
||||
* `clearance_charge.invoice.paid` event marks it PAID. The two levels are
|
||||
* independent — either may be raised first.
|
||||
* 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'])
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -3,6 +3,10 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Company } from './entities/company.entity';
|
||||
import {
|
||||
companyDraftSql,
|
||||
companyPendingChangeRequestSql,
|
||||
} from './company-scope.sql';
|
||||
import { ListCompaniesQueryDto } from './dto/list-companies-query.dto';
|
||||
import { CompanyStatsResponseDto } from './dto/company-stats-response.dto';
|
||||
|
||||
@@ -15,31 +19,10 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
* placeholder name + TIN, so it must not be offered up for review.
|
||||
* Staff-created companies have no external profiles and are never drafts.
|
||||
*/
|
||||
private static readonly DRAFT_SQL = `(
|
||||
EXISTS (
|
||||
SELECT 1 FROM freight.external_profiles ep
|
||||
WHERE ep.company_id = company.id
|
||||
AND ep.deleted_at IS NULL
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.external_profiles ep
|
||||
WHERE ep.company_id = company.id
|
||||
AND ep.deleted_at IS NULL
|
||||
AND ep.onboarding_completed = true
|
||||
)
|
||||
)`;
|
||||
private static readonly DRAFT_SQL = companyDraftSql('company');
|
||||
|
||||
/**
|
||||
* A company waiting on a reviewer to decide an edit it submitted after being
|
||||
* approved. These rows are `status = active`, so the pending-application filter
|
||||
* can never surface them — the review queue needs its own predicate.
|
||||
*/
|
||||
private static readonly PENDING_CHANGE_REQUEST_SQL = `EXISTS (
|
||||
SELECT 1 FROM freight.company_change_request ccr
|
||||
WHERE ccr.company_id = company.id
|
||||
AND ccr.status = 'pending'
|
||||
AND ccr.deleted_at IS NULL
|
||||
)`;
|
||||
private static readonly PENDING_CHANGE_REQUEST_SQL =
|
||||
companyPendingChangeRequestSql('company');
|
||||
|
||||
/**
|
||||
* The `sortBy = 'review'` queue ordering: whatever marketing must act on
|
||||
@@ -96,6 +79,9 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
type,
|
||||
kind,
|
||||
status,
|
||||
nationality,
|
||||
createdFrom,
|
||||
createdTo,
|
||||
onboardingCompleted,
|
||||
hasPendingChangeRequest,
|
||||
sortBy = 'review',
|
||||
@@ -122,6 +108,18 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
qb.andWhere('company.status = :status', { status });
|
||||
}
|
||||
|
||||
if (nationality) {
|
||||
qb.andWhere('company.nationality = :nationality', { nationality });
|
||||
}
|
||||
|
||||
if (createdFrom) {
|
||||
qb.andWhere('company.createdAt >= :createdFrom', { createdFrom });
|
||||
}
|
||||
|
||||
if (createdTo) {
|
||||
qb.andWhere('company.createdAt <= :createdTo', { createdTo });
|
||||
}
|
||||
|
||||
if (onboardingCompleted !== undefined) {
|
||||
qb.andWhere(
|
||||
onboardingCompleted
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Two predicates that define a customer's review state but are NOT columns on
|
||||
* `companies`. Shared verbatim by the list repository and the export dataset —
|
||||
* the backoffice offers both as one Status filter, so an export that computed
|
||||
* "onboarding draft" differently from the list would quietly disagree with the
|
||||
* screen it was launched from.
|
||||
*
|
||||
* Each takes the query's table alias because the two callers use different
|
||||
* ones (`company` in the repository, `c` in the dataset).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Still in the portal onboarding wizard: has at least one external profile,
|
||||
* none of them submitted. Such a row exists from the wizard's first click, so
|
||||
* it must be excluded from the awaiting-approval queue.
|
||||
*/
|
||||
export const companyDraftSql = (alias: string): string => `(
|
||||
EXISTS (
|
||||
SELECT 1 FROM freight.external_profiles ep
|
||||
WHERE ep.company_id = ${alias}.id
|
||||
AND ep.deleted_at IS NULL
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.external_profiles ep
|
||||
WHERE ep.company_id = ${alias}.id
|
||||
AND ep.deleted_at IS NULL
|
||||
AND ep.onboarding_completed = true
|
||||
)
|
||||
)`;
|
||||
|
||||
/**
|
||||
* An already-approved customer who edited their profile: they stay
|
||||
* `status = active`, so no status filter can ever surface them.
|
||||
*/
|
||||
export const companyPendingChangeRequestSql = (alias: string): string => `EXISTS (
|
||||
SELECT 1 FROM freight.company_change_request ccr
|
||||
WHERE ccr.company_id = ${alias}.id
|
||||
AND ccr.status = 'pending'
|
||||
AND ccr.deleted_at IS NULL
|
||||
)`;
|
||||
@@ -1,7 +1,20 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from "class-validator";
|
||||
import { Transform } from "class-transformer";
|
||||
import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity";
|
||||
import {
|
||||
CompanyKind,
|
||||
CompanyNationality,
|
||||
CompanyStatus,
|
||||
CompanyType,
|
||||
} from "../entities/company.entity";
|
||||
|
||||
export class ListCompaniesQueryDto {
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@@ -38,6 +51,21 @@ export class ListCompaniesQueryDto {
|
||||
@IsIn(Object.values(CompanyStatus))
|
||||
status?: CompanyStatus;
|
||||
|
||||
@ApiPropertyOptional({ enum: CompanyNationality })
|
||||
@IsOptional()
|
||||
@IsIn(Object.values(CompanyNationality))
|
||||
nationality?: CompanyNationality;
|
||||
|
||||
@ApiPropertyOptional({ description: "Registered on or after this instant (ISO)." })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
createdFrom?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Registered on or before this instant (ISO)." })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
createdTo?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Filter by onboarding submission. `true` = reviewable applications; " +
|
||||
|
||||
@@ -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';
|
||||
@@ -268,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',
|
||||
@@ -345,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);
|
||||
@@ -782,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.
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
@@ -2071,6 +2091,7 @@ export class ContractBookingService {
|
||||
): Promise<{
|
||||
overweightLines: Array<{
|
||||
containerTypeCode: string;
|
||||
containerLabel: string;
|
||||
totalVgmTons: number;
|
||||
maxAllowedTons: number;
|
||||
excessTons: number;
|
||||
@@ -2180,6 +2201,12 @@ export class ContractBookingService {
|
||||
: 0,
|
||||
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
|
||||
totalVgmTons,
|
||||
// Per-box weights drive the overweight check — the limit is per
|
||||
// container, so a heavy box is billed even when the line total fits.
|
||||
units: (line.units ?? []).map((u, idx) => ({
|
||||
vgmTons: Number(u.vgmTons ?? 0),
|
||||
sortOrder: idx,
|
||||
})) as BookingContainer['units'],
|
||||
wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt)),
|
||||
}),
|
||||
),
|
||||
@@ -2213,6 +2240,7 @@ export class ContractBookingService {
|
||||
containerTypeId: ct.id,
|
||||
quantity: line.quantity,
|
||||
totalVgmTons,
|
||||
unitVgmTons: (line.units ?? []).map((u) => Number(u.vgmTons ?? 0)),
|
||||
})),
|
||||
contract.tradeDirection,
|
||||
);
|
||||
@@ -2292,7 +2320,12 @@ export class ContractBookingService {
|
||||
(s, u) => s + Number(u.vgmTons ?? 0),
|
||||
0,
|
||||
);
|
||||
return { containerTypeId: ct.id, quantity: line.quantity, totalVgmTons };
|
||||
return {
|
||||
containerTypeId: ct.id,
|
||||
quantity: line.quantity,
|
||||
totalVgmTons,
|
||||
unitVgmTons: (line.units ?? []).map((u) => Number(u.vgmTons ?? 0)),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -2458,22 +2491,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) =>
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
type ClearanceTrainState,
|
||||
} from '@edr/types';
|
||||
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
||||
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
@@ -117,6 +119,18 @@ export interface ContractClearanceView {
|
||||
linkedBookingReviewNote?: string | null;
|
||||
/** Shipment day the booking currently holds — the default when GL resubmits. */
|
||||
linkedBookingScheduledDate?: string | null;
|
||||
/**
|
||||
* Open wagon-cancellation on a CANCELLED linked booking (consolidation
|
||||
* partner lapsed, staff cut): FEE_PENDING = customer must pay the
|
||||
* cancellation fee; CREDIT_AVAILABLE = fee settled, GL rebooks the credit.
|
||||
*/
|
||||
linkedBookingCancellation?: {
|
||||
id: string;
|
||||
status: string;
|
||||
wagonsCancelled: number;
|
||||
creditAmount: number;
|
||||
creditCurrency: string;
|
||||
} | null;
|
||||
dutyAdvice?: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
@@ -171,6 +185,7 @@ export class ContractClearanceService {
|
||||
private readonly glOperationsService: GlOperationsService,
|
||||
private readonly notifier: ContractNotifierService,
|
||||
private readonly transitAgentsService: TransitAgentsService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
private isPhasedCustoms(contract: Contract): boolean {
|
||||
@@ -330,7 +345,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 +357,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);
|
||||
@@ -368,9 +384,27 @@ export class ContractClearanceService {
|
||||
// without a cycle row), so fall back to the contract's own live booking —
|
||||
// otherwise the clearance page sees no linked booking at all and cannot show
|
||||
// its status or the actions that depend on it.
|
||||
const booking = cycle?.bookingId
|
||||
let booking = cycle?.bookingId
|
||||
? await this.bookingsService.findById(cycle.bookingId)
|
||||
: await this.contractsRepository.findLatestBookingForContract(contractId);
|
||||
// The fallback skips terminal bookings, but a CANCELLED one with an open
|
||||
// wagon-cancellation still belongs on this page: the fee gate and the
|
||||
// rebook-from-credit action live here. Surface the newest such booking.
|
||||
if (!booking) {
|
||||
const [open] = await this.dataSource.query<{ booking_id: string }[]>(
|
||||
`SELECT c.booking_id
|
||||
FROM freight.booking_wagon_cancellations c
|
||||
JOIN freight.bookings b ON b.id = c.booking_id
|
||||
WHERE b.contract_id = $1
|
||||
AND b.status = 'CANCELLED'
|
||||
AND c.status IN ('FEE_PENDING', 'CREDIT_AVAILABLE')
|
||||
AND c.deleted_at IS NULL
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT 1`,
|
||||
[contractId],
|
||||
);
|
||||
if (open) booking = await this.bookingsService.findById(open.booking_id);
|
||||
}
|
||||
if (booking) {
|
||||
linkedBookingId = booking.id ?? null;
|
||||
linkedBookingReference = booking.reference ?? null;
|
||||
@@ -394,6 +428,40 @@ export class ContractClearanceService {
|
||||
}
|
||||
}
|
||||
|
||||
// A CANCELLED booking may carry an open wagon-cancellation (consolidation
|
||||
// partner lapsed, staff cut): FEE_PENDING gates on the customer paying the
|
||||
// cancellation fee; CREDIT_AVAILABLE lets GL rebook from the credit here.
|
||||
let linkedBookingCancellation: {
|
||||
id: string;
|
||||
status: string;
|
||||
wagonsCancelled: number;
|
||||
creditAmount: number;
|
||||
creditCurrency: string;
|
||||
} | null = null;
|
||||
if (booking && linkedBookingStatus === 'CANCELLED') {
|
||||
const [row] = await this.dataSource.query<
|
||||
{ id: string; status: string; wagons_cancelled: string; credit_amount: string }[]
|
||||
>(
|
||||
`SELECT id, status, wagons_cancelled, credit_amount
|
||||
FROM freight.booking_wagon_cancellations
|
||||
WHERE booking_id = $1
|
||||
AND status IN ('FEE_PENDING', 'CREDIT_AVAILABLE')
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`,
|
||||
[booking.id],
|
||||
);
|
||||
if (row) {
|
||||
linkedBookingCancellation = {
|
||||
id: row.id,
|
||||
status: row.status,
|
||||
wagonsCancelled: Number(row.wagons_cancelled),
|
||||
creditAmount: Number(row.credit_amount),
|
||||
creditCurrency: booking.paymentCurrency ?? 'ETB',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contractId,
|
||||
status: contract.status,
|
||||
@@ -432,6 +500,7 @@ export class ContractClearanceService {
|
||||
linkedBookingStatus,
|
||||
linkedBookingReviewNote,
|
||||
linkedBookingScheduledDate,
|
||||
linkedBookingCancellation,
|
||||
dutyAdvice,
|
||||
dutyDispute,
|
||||
transitAssignee,
|
||||
|
||||
@@ -63,6 +63,7 @@ describe('ContractClearanceService — duty dispute', () => {
|
||||
{} as never, // glOperationsService
|
||||
notifier as never,
|
||||
{} as never, // transitAgentsService
|
||||
{} as never, // dataSource
|
||||
);
|
||||
build([
|
||||
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -61,6 +61,7 @@ describe('ContractClearanceService — transit assignee', () => {
|
||||
{} as never,
|
||||
notifier as never,
|
||||
transitAgentsService as never,
|
||||
{} as never, // dataSource
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsIn, IsNumber, IsOptional, IsString, Length } from "class-validator";
|
||||
|
||||
import { EIMS_MODE_OF_PAYMENT, EimsModeOfPayment } from "../eims-receipt.types";
|
||||
@@ -9,9 +9,15 @@ import { EIMS_MODE_OF_PAYMENT, EimsModeOfPayment } from "../eims-receipt.types";
|
||||
* guessed (payment method, collector, provider references — none of it is modelled on `Invoice`).
|
||||
*/
|
||||
export class RegisterSalesReceiptDto {
|
||||
@ApiProperty({ enum: EIMS_MODE_OF_PAYMENT, description: "MoR's confirmed ModeOfPayment enum." })
|
||||
@ApiPropertyOptional({
|
||||
enum: EIMS_MODE_OF_PAYMENT,
|
||||
description:
|
||||
"MoR's confirmed ModeOfPayment enum. Optional when the invoice's recorded payment method " +
|
||||
"maps unambiguously (CASH, CHEQUE, CPO, CARD, BANK_TRANSFER); otherwise required.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(EIMS_MODE_OF_PAYMENT)
|
||||
modeOfPayment!: EimsModeOfPayment;
|
||||
modeOfPayment?: EimsModeOfPayment;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Defaults to "Payment received".' })
|
||||
@IsOptional()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "" } });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -116,6 +116,47 @@ describe("EimsReceiptService.registerSalesReceipt", () => {
|
||||
expect(receipt.qr).toBe("iVBORw0KGgo...");
|
||||
});
|
||||
|
||||
it("derives mode/date/voucher/amount from the recorded manual payment", async () => {
|
||||
const db = new FakeDb([
|
||||
invoiceRow({
|
||||
payments: [
|
||||
{ amount: 4000, method: "CASH", reference: "CRV-000123", paidAt: "2026-08-20T09:00:00.000Z", metadata: null },
|
||||
],
|
||||
} as never),
|
||||
]);
|
||||
const postBearer = jest.fn().mockResolvedValue(okResponse());
|
||||
|
||||
await build(db, postBearer).registerSalesReceipt(INVOICE_ID, {} as never);
|
||||
|
||||
const request = postBearer.mock.calls[0][1];
|
||||
expect(request.TransactionDetails.ModeOfPayment).toBe("CASH");
|
||||
expect(request.ManualReceiptNumber).toBe("CRV-000123");
|
||||
expect(request.ReceiptDate).toBe("2026-08-20T09:00:00.000Z");
|
||||
expect(request.CollectedAmount).toBe(4000);
|
||||
});
|
||||
|
||||
it("puts a gateway reference in TransactionNumber, never ManualReceiptNumber, and demands an explicit mode", async () => {
|
||||
const db = new FakeDb([
|
||||
invoiceRow({
|
||||
payments: [
|
||||
{ amount: 10000, method: "GATEWAY", reference: "txn-9f8e7d", paidAt: "2026-08-21T10:00:00.000Z", metadata: null },
|
||||
],
|
||||
} as never),
|
||||
]);
|
||||
const postBearer = jest.fn().mockResolvedValue(okResponse());
|
||||
const service = build(db, postBearer);
|
||||
|
||||
// GATEWAY says nothing about the channel — deriving would guess a tax field.
|
||||
await expect(service.registerSalesReceipt(INVOICE_ID, {} as never)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
await service.registerSalesReceipt(INVOICE_ID, { modeOfPayment: "Card" } as never);
|
||||
const request = postBearer.mock.calls[0][1];
|
||||
expect(request.TransactionDetails.TransactionNumber).toBe("txn-9f8e7d");
|
||||
expect(request.ManualReceiptNumber).not.toBe("txn-9f8e7d");
|
||||
});
|
||||
|
||||
it("defaults PaymentCoverage to FULL when the invoice balance is 0, PARTIAL otherwise", async () => {
|
||||
const db = new FakeDb([invoiceRow({ balanceAmount: 500 })]);
|
||||
const postBearer = jest.fn().mockResolvedValue(okResponse());
|
||||
|
||||
@@ -17,6 +17,8 @@ import { EimsReceipt, EimsReceiptKind, EimsReceiptStatus } from "./entities/eims
|
||||
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
|
||||
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
|
||||
import {
|
||||
EIMS_MODE_OF_PAYMENT,
|
||||
EimsModeOfPayment,
|
||||
EimsReceiptResponse,
|
||||
EimsSalesReceiptRequest,
|
||||
EimsWithholdReceiptRequest,
|
||||
@@ -41,8 +43,11 @@ const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AU
|
||||
* double-submission guard the way `/v1/cancel` does ("IRN already Canceled."), so the same caution
|
||||
* applies as an unacknowledged registration: a human must check the MoR portal first.
|
||||
*
|
||||
* Several request fields have no confirmed source in this codebase (payment method, collector,
|
||||
* withholding rate/amount) and are never guessed — see the two DTOs.
|
||||
* Sales receipts derive what the invoice's payment ledger actually records — amount, date,
|
||||
* finance's voucher number (ManualReceiptNumber), gateway transaction id, and the payment mode
|
||||
* where the ledger method maps unambiguously to MoR's enum. Fields with no recorded source
|
||||
* (collector, withholding rate/amount, mobile-money modes) are still asked of the caller, never
|
||||
* guessed — see the two DTOs.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EimsReceiptService {
|
||||
@@ -68,7 +73,26 @@ export class EimsReceiptService {
|
||||
|
||||
const session = await this.auth.getSessionContext();
|
||||
const receiptNumber = this.generateReceiptNumber(invoice);
|
||||
const collectedAmount = dto.collectedAmount ?? Number(invoice.paidAmount);
|
||||
|
||||
// The newest ledger entry is the payment this receipt vouches for. A gateway settlement's
|
||||
// `reference` is the provider transaction id; a manual settlement's `reference` is finance's
|
||||
// own voucher number (CRV) — that one belongs in ManualReceiptNumber so the registered
|
||||
// receipt matches finance's books.
|
||||
const lastPayment = invoice.payments?.length
|
||||
? invoice.payments[invoice.payments.length - 1]
|
||||
: null;
|
||||
const isGateway = (lastPayment?.method ?? "").toUpperCase() === "GATEWAY";
|
||||
const modeOfPayment = dto.modeOfPayment ?? deriveModeOfPayment(lastPayment?.method);
|
||||
if (!modeOfPayment) {
|
||||
throw new BadRequestException({
|
||||
code: "EIMS_MODE_OF_PAYMENT_REQUIRED",
|
||||
message:
|
||||
`Recorded payment method "${lastPayment?.method ?? "none"}" has no unambiguous MoR ` +
|
||||
`ModeOfPayment — pass modeOfPayment (one of ${EIMS_MODE_OF_PAYMENT.join(", ")}).`,
|
||||
});
|
||||
}
|
||||
const collectedAmount =
|
||||
dto.collectedAmount ?? (lastPayment ? lastPayment.amount : Number(invoice.paidAmount));
|
||||
const balance = Number(invoice.balanceAmount);
|
||||
|
||||
const request: EimsSalesReceiptRequest = {
|
||||
@@ -77,9 +101,9 @@ export class EimsReceiptService {
|
||||
Reason: dto.reason ?? "Payment received",
|
||||
// ISO-8601 UTC — the collection's saved example uses a "+03:00" offset instead; no schema
|
||||
// error for this field was ever observed to confirm which form MoR actually requires.
|
||||
ReceiptDate: new Date().toISOString(),
|
||||
ReceiptDate: lastPayment?.paidAt ?? new Date().toISOString(),
|
||||
ReceiptCounter: String(Date.now()),
|
||||
ManualReceiptNumber: receiptNumber,
|
||||
ManualReceiptNumber: (!isGateway && lastPayment?.reference) || receiptNumber,
|
||||
SourceSystemType: session.systemType,
|
||||
SourceSystemNumber: session.systemNumber,
|
||||
ReceiptCurrency: currency,
|
||||
@@ -97,7 +121,7 @@ export class EimsReceiptService {
|
||||
},
|
||||
],
|
||||
TransactionDetails: {
|
||||
ModeOfPayment: dto.modeOfPayment,
|
||||
ModeOfPayment: modeOfPayment,
|
||||
ChequeNumber: dto.chequeNumber ?? null,
|
||||
CPONumber: dto.cpoNumber ?? null,
|
||||
DocumentNumber: dto.documentNumber ?? null,
|
||||
@@ -105,7 +129,7 @@ export class EimsReceiptService {
|
||||
PaymentServiceProvider: dto.paymentServiceProvider ?? null,
|
||||
OtherPaymentServiceProviderName: dto.otherPaymentServiceProviderName ?? null,
|
||||
AccountNumber: dto.accountNumber ?? null,
|
||||
TransactionNumber: dto.transactionNumber ?? null,
|
||||
TransactionNumber: dto.transactionNumber ?? (isGateway ? (lastPayment?.reference ?? null) : null),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -286,3 +310,17 @@ export class EimsReceiptService {
|
||||
return `REC-${invoice.invoiceNumber}-${Date.now()}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recorded ledger method → MoR ModeOfPayment, only where the mapping is unambiguous. Mobile-money
|
||||
* methods (TELEBIRR, EBIRR, …) have no MoR enum slot, and "GATEWAY" says nothing about the real
|
||||
* channel — those return undefined and the caller must supply modeOfPayment explicitly. Guessing
|
||||
* a tax field is worse than asking.
|
||||
*/
|
||||
function deriveModeOfPayment(method: string | null | undefined): EimsModeOfPayment | undefined {
|
||||
if (!method) return undefined;
|
||||
const normalized = method.toUpperCase().replace(/-/g, "_");
|
||||
const direct = EIMS_MODE_OF_PAYMENT.find((m) => m.toUpperCase().replace(/ /g, "_") === normalized);
|
||||
if (direct) return direct;
|
||||
return normalized === "BANK_TRANSFER" ? "Local Bank Transfer" : undefined;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -13,7 +13,7 @@ import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
|
||||
import { ExportDataset } from '../export.types';
|
||||
|
||||
/**
|
||||
* Domain semantics shared with `reports/definitions/bookings-list.report.ts`.
|
||||
* Domain semantics that the retired `bookings-list` report used to share.
|
||||
* Kept identical on purpose — for PER_ITEM bulk bookings `cargo_total_weight_vgm`
|
||||
* holds an item COUNT, not tonnage, and `adjusted_total_amount` silently
|
||||
* overrides `total_amount`. Getting either wrong misreports money or weight.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import {
|
||||
companyDraftSql,
|
||||
companyPendingChangeRequestSql,
|
||||
} from '../../companies/company-scope.sql';
|
||||
import { ExportDataset } from '../export.types';
|
||||
|
||||
/**
|
||||
@@ -114,6 +118,21 @@ export const customersDataset: ExportDataset = {
|
||||
{ value: 'government', label: 'Government' },
|
||||
] },
|
||||
{ key: 'status', label: 'Status', type: 'text' },
|
||||
{ key: 'nationality', label: 'Nationality', type: 'select', options: [
|
||||
{ value: 'ethiopian', label: 'Ethiopian' },
|
||||
{ value: 'foreign', label: 'Foreign' },
|
||||
] },
|
||||
// The list's Status filter folds the review queues in, and sends these two
|
||||
// alongside `status`. They are predicates, not columns — see
|
||||
// `company-scope.sql.ts`, shared with the list so both agree exactly.
|
||||
{ key: 'onboardingCompleted', label: 'Onboarding submitted', type: 'select', options: [
|
||||
{ value: 'true', label: 'Submitted' },
|
||||
{ value: 'false', label: 'Still a draft' },
|
||||
] },
|
||||
{ key: 'hasPendingChangeRequest', label: 'Pending profile changes', type: 'select', options: [
|
||||
{ value: 'true', label: 'Awaiting review' },
|
||||
{ value: 'false', label: 'None open' },
|
||||
] },
|
||||
{ key: 'search', label: 'Search name, TIN or email', type: 'text' },
|
||||
],
|
||||
|
||||
@@ -127,6 +146,15 @@ export const customersDataset: ExportDataset = {
|
||||
if (params.type) qb.andWhere('c.type = :type', { type: params.type });
|
||||
if (params.kind) qb.andWhere('c.kind = :kind', { kind: params.kind });
|
||||
if (params.status) qb.andWhere('c.status = :status', { status: params.status });
|
||||
if (params.nationality) qb.andWhere('c.nationality = :nationality', { nationality: params.nationality });
|
||||
if (params.onboardingCompleted) {
|
||||
const draft = companyDraftSql('c');
|
||||
qb.andWhere(params.onboardingCompleted === 'true' ? `NOT ${draft}` : draft);
|
||||
}
|
||||
if (params.hasPendingChangeRequest) {
|
||||
const pending = companyPendingChangeRequestSql('c');
|
||||
qb.andWhere(params.hasPendingChangeRequest === 'true' ? pending : `NOT ${pending}`);
|
||||
}
|
||||
if (params.search) {
|
||||
qb.andWhere('(c.name ILIKE :search OR c.tin ILIKE :search OR c.email ILIKE :search)', {
|
||||
search: `%${params.search as string}%`,
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||
import { Invoice } from '../../billing/entities/invoice.entity';
|
||||
import { invoicePaymentMethodExpr } from '../../billing/invoice-settlement.util';
|
||||
import { PaymentEntity } from '../../payment/entities/payment.entity';
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
|
||||
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
|
||||
import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util';
|
||||
import { ExportDataset } from '../export.types';
|
||||
|
||||
/** Same expression the list endpoint filters by, in this dataset's aliases. */
|
||||
const PAYMENT_METHOD = invoicePaymentMethodExpr('i', 'p');
|
||||
|
||||
/**
|
||||
* Sensitive EIMS internals are deliberately absent: `eims_signed_qr` (a
|
||||
* signature blob) and `eims_last_error` (a raw error dump). The
|
||||
@@ -26,8 +32,16 @@ export const invoicesDataset: ExportDataset = {
|
||||
// with a second query. In a dataset it is just a join by column.
|
||||
{ alias: 'slc', entity: ShippingLineCompany, on: 'slc.id = i.shipping_line_company_id' },
|
||||
{ alias: 'rel', entity: Invoice, on: 'rel.id = i.related_invoice_id' },
|
||||
// The gateway payment behind the invoice — provider method and its
|
||||
// transaction reference. Always joined: `scope()` filters on it.
|
||||
{ alias: 'p', entity: PaymentEntity, on: 'p.id = i.payment_id' },
|
||||
// Booking behind the invoice, for the PNR alone. `i.source_id` is a bare
|
||||
// varchar pointer that is not always a UUID (EIMS self-test rows carry a
|
||||
// slug), so the cast goes on `bk.id`, never on `source_id` — casting the
|
||||
// other way throws on those rows.
|
||||
{ alias: 'bk', entity: Booking, on: "bk.id::text = i.source_id AND i.source = 'booking'" },
|
||||
],
|
||||
alwaysJoin: ['c'],
|
||||
alwaysJoin: ['c', 'p', 'bk'],
|
||||
|
||||
groups: [
|
||||
{ id: 'invoice', label: 'Invoice' },
|
||||
@@ -66,6 +80,12 @@ export const invoicesDataset: ExportDataset = {
|
||||
{ key: 'currency', label: 'Currency', type: 'string', group: 'amounts', default: true, select: 'i.currency' },
|
||||
|
||||
{ key: 'paidAt', label: 'Paid at', type: 'datetime', group: 'payment', select: `to_char(i.paid_at, 'YYYY-MM-DD HH24:MI')` },
|
||||
{ key: 'paymentMethod', label: 'Payment method', type: 'string', group: 'payment', default: true, requires: ['p'], select: PAYMENT_METHOD, sortExpr: PAYMENT_METHOD },
|
||||
{ key: 'transactionRef', label: 'Transaction ref', type: 'string', group: 'payment', requires: ['p'], select: 'p.transaction_id' },
|
||||
// The CBE_BILL reference the customer pays against — stamped onto the
|
||||
// booking at payment-initiation time, not held on the invoice or payment.
|
||||
{ key: 'pnrCode', label: 'PNR', type: 'string', group: 'payment', requires: ['bk'], select: 'bk.pnr_code' },
|
||||
{ key: 'paymentStatus', label: 'Payment status', type: 'string', group: 'payment', requires: ['p'], select: 'p.status::text' },
|
||||
{
|
||||
key: 'daysOverdue', label: 'Days overdue', type: 'number', group: 'payment',
|
||||
select: `CASE WHEN i.balance_amount > 0 AND i.due_at < now()
|
||||
@@ -97,16 +117,24 @@ export const invoicesDataset: ExportDataset = {
|
||||
|
||||
filters: [
|
||||
{ key: 'issued', label: 'Issued', type: 'daterange' },
|
||||
{ key: 'due', label: 'Due', type: 'daterange' },
|
||||
{ key: 'statuses', label: 'Status', type: 'multiselect' },
|
||||
// The invoices list page sends a single `status`; accept both so its
|
||||
// on-screen filter actually carries into the export.
|
||||
{ key: 'status', label: 'Status (single)', type: 'text' },
|
||||
{ key: 'sources', label: 'Source', type: 'multiselect' },
|
||||
{ key: 'eimsStatuses', label: 'EIMS status', type: 'multiselect' },
|
||||
{ key: 'paymentMethods', label: 'Payment method', type: 'multiselect' },
|
||||
{ key: 'currency', label: 'Currency', type: 'select', options: [
|
||||
{ value: 'ETB', label: 'ETB' },
|
||||
{ value: 'USD', label: 'USD' },
|
||||
] },
|
||||
{ key: 'minAmount', label: 'Min total', type: 'text' },
|
||||
{ key: 'maxAmount', label: 'Max total', type: 'text' },
|
||||
{ key: 'hasBalance', label: 'Outstanding only', type: 'text' },
|
||||
{ key: 'overdue', label: 'Overdue only', type: 'text' },
|
||||
{ key: 'companyId', label: 'Customer', type: 'text' },
|
||||
{ key: 'search', label: 'Search invoice no. or customer', type: 'text' },
|
||||
{ key: 'search', label: 'Search invoice no., customer, PNR or transaction ref', type: 'text' },
|
||||
],
|
||||
|
||||
defaultSort: { key: 'issuedAt', dir: 'DESC' },
|
||||
@@ -116,13 +144,45 @@ export const invoicesDataset: ExportDataset = {
|
||||
qb.andWhere('i.deleted_at IS NULL');
|
||||
if (params.issuedFrom) qb.andWhere('i.issued_at >= :issuedFrom', { issuedFrom: params.issuedFrom });
|
||||
if (params.issuedTo) qb.andWhere('i.issued_at < :issuedTo', { issuedTo: params.issuedTo });
|
||||
if (params.dueFrom) qb.andWhere('i.due_at >= :dueFrom', { dueFrom: params.dueFrom });
|
||||
if (params.dueTo) qb.andWhere('i.due_at < :dueTo', { dueTo: params.dueTo });
|
||||
const statuses = params.statuses as string[] | null;
|
||||
if (statuses?.length) qb.andWhere('i.status IN (:...statuses)', { statuses });
|
||||
if (params.status) qb.andWhere('i.status = :status', { status: params.status });
|
||||
if (params.currency) qb.andWhere('i.currency = :currency', { currency: params.currency });
|
||||
const sources = params.sources as string[] | null;
|
||||
if (sources?.length) qb.andWhere('i.source IN (:...sources)', { sources });
|
||||
const eimsStatuses = params.eimsStatuses as string[] | null;
|
||||
if (eimsStatuses?.length) qb.andWhere('i.eims_status IN (:...eimsStatuses)', { eimsStatuses });
|
||||
const paymentMethods = params.paymentMethods as string[] | null;
|
||||
if (paymentMethods?.length) {
|
||||
qb.andWhere(`${PAYMENT_METHOD} IN (:...paymentMethods)`, { paymentMethods });
|
||||
}
|
||||
// Casing has drifted in the data ("usd" rows exist) — normalise both sides,
|
||||
// same as the list endpoint does.
|
||||
if (params.currency) {
|
||||
qb.andWhere('UPPER(i.currency) = :currency', {
|
||||
currency: String(params.currency).toUpperCase(),
|
||||
});
|
||||
}
|
||||
if (params.minAmount) qb.andWhere('i.total_amount >= :minAmount', { minAmount: Number(params.minAmount) });
|
||||
if (params.maxAmount) qb.andWhere('i.total_amount <= :maxAmount', { maxAmount: Number(params.maxAmount) });
|
||||
if (params.hasBalance === 'true') qb.andWhere('i.balance_amount > 0');
|
||||
// Computed, not `status = OVERDUE` — nothing sweeps PENDING rows into it.
|
||||
if (params.overdue === 'true') qb.andWhere('i.balance_amount > 0 AND i.due_at < now()');
|
||||
if (params.companyId) qb.andWhere('i.company_id = :companyId', { companyId: params.companyId });
|
||||
if (params.search) {
|
||||
qb.andWhere('(i.invoice_number ILIKE :search OR c.name ILIKE :search)', { search: `%${params.search as string}%` });
|
||||
// Same reach as the list page's search box, minus the source-record
|
||||
// lookups it does with correlated subqueries: number, customer, the PNR
|
||||
// the customer pays against, and the payment references support desks
|
||||
// quote back.
|
||||
qb.andWhere(
|
||||
`(i.invoice_number ILIKE :search
|
||||
OR c.name ILIKE :search
|
||||
OR bk.pnr_code ILIKE :search
|
||||
OR p.transaction_id ILIKE :search
|
||||
OR p.merchant_order_id ILIKE :search)`,
|
||||
{ search: `%${params.search as string}%` },
|
||||
);
|
||||
}
|
||||
// ACL: invoices.source_id is a varchar pointer at the originating booking.
|
||||
applyBookingRefDirectionScope(qb, 'i.source_id', directions);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
const dateTime = (value: unknown) =>
|
||||
value ? new Date(value as string | Date).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : '-';
|
||||
const carrier =
|
||||
row.returnedBy === 'EDR'
|
||||
? 'EDR Last Mile'
|
||||
: row.returnedBy === 'CUSTOMER'
|
||||
? 'Customer Self-Haul'
|
||||
: '-';
|
||||
|
||||
const rows: Array<[string, string]> = [
|
||||
['Container Number', row.containerNumber],
|
||||
['Container Size', row.containerSize ? `${row.containerSize}ft` : 'Not recorded'],
|
||||
['Date & Time of Return', dateTime(row.returnDate)],
|
||||
['Depot / Location', [row.facility, row.yard, row.zone].filter(Boolean).join(' — ') || '-'],
|
||||
['Condition Status', row.condition || 'Good — no exceptions noted'],
|
||||
['Carrier', carrier],
|
||||
['Booking / BOL Reference', booking?.reference || 'Standalone — no booking'],
|
||||
['Shipping Line / Customer', booking?.company_name || '-'],
|
||||
['Current Status', row.status.replace(/_/g, ' ')],
|
||||
['Handover Note', row.handoverNote || '-'],
|
||||
];
|
||||
|
||||
const rowsHtml = rows
|
||||
.map(
|
||||
([label, value]) =>
|
||||
`<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;
|
||||
|
||||
@@ -58,13 +58,18 @@ export class CreateOperationsTargetDto {
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Station targets only: which cargo category this station plan covers. Leave blank for the other dimensions.',
|
||||
'Station targets only: which cargo category this station plan covers. Ignored for the ' +
|
||||
'other dimensions, whose key already carries the category.',
|
||||
example: 'CONTAINER_IMPORT_MULTIMODAL',
|
||||
})
|
||||
@IsOptional()
|
||||
// `'' ?? null` is `''`, and an empty string matches neither the unique
|
||||
// index's `COALESCE(cargo_category, '')` nor the report's join — it reads as
|
||||
// a category that does not exist. Blank means absent.
|
||||
@Transform(({ value }) => (value === '' ? null : value))
|
||||
@IsString()
|
||||
@MaxLength(60)
|
||||
cargoCategory?: string;
|
||||
cargoCategory?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
|
||||
@@ -67,6 +67,24 @@ export class UpdateOperationsStandardsDto {
|
||||
@Min(0)
|
||||
delayToleranceMinutes?: number;
|
||||
|
||||
/**
|
||||
* Handling standards have no spec figure, so they are the only two that may
|
||||
* be cleared: null puts the report back to reporting hours without a rate.
|
||||
*/
|
||||
@ApiPropertyOptional({ example: 6.75, nullable: true })
|
||||
@IsOptional()
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
handlingStandardHoursContainer?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ example: 12, nullable: true })
|
||||
@IsOptional()
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
handlingStandardHoursBulk?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ example: 20 })
|
||||
@IsOptional()
|
||||
@Transform(toNumber)
|
||||
|
||||
@@ -179,6 +179,34 @@ export class OperationsStandard extends BaseEntity {
|
||||
@Column({ name: 'default_full_trainset_wagons', type: 'int', default: 50 })
|
||||
defaultFullTrainsetWagons!: number;
|
||||
|
||||
/**
|
||||
* Standard loading-and-unloading time for a container train's stop, in hours.
|
||||
*
|
||||
* Null until a planner sets it, and deliberately so: the reporting spec names
|
||||
* no handling standard, so an unset value reports no rate rather than judging
|
||||
* a train against a guess. Same for the bulk figure below.
|
||||
*/
|
||||
@Column({
|
||||
name: 'handling_standard_hours_container',
|
||||
type: 'numeric',
|
||||
precision: 6,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
transformer: asNumber,
|
||||
})
|
||||
handlingStandardHoursContainer?: number | null;
|
||||
|
||||
/** Standard loading-and-unloading time for a bulk train's stop, in hours. */
|
||||
@Column({
|
||||
name: 'handling_standard_hours_bulk',
|
||||
type: 'numeric',
|
||||
precision: 6,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
transformer: asNumber,
|
||||
})
|
||||
handlingStandardHoursBulk?: number | null;
|
||||
|
||||
/** IAM user id of the last operator to change a standard. */
|
||||
@Column({ name: 'updated_by_id', type: 'uuid', nullable: true })
|
||||
updatedById?: string | null;
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
/** Planning buckets the reports offer. Mirrors the reports' period filter. */
|
||||
export const TARGET_PERIOD_TYPES = ['week', 'month', 'quarter', 'year'] as const;
|
||||
/**
|
||||
* Planning buckets the reports offer. Mirrors the reports' period filter
|
||||
* (`PERIOD_UNITS` in `reports/revenue-classification.ts`) — a planner must be
|
||||
* able to commit a number at whatever grain the business quotes it, and the
|
||||
* report then re-gathers it into whatever grain the viewer asks for.
|
||||
*
|
||||
* All eight anchor to the calendar year. `nine_month` and `ninety_day` are the
|
||||
* two that do not divide it evenly: their last block of a year is short (Oct–Dec
|
||||
* and the 5–6 days after day 360). That is inherent to the unit, not a bug.
|
||||
*/
|
||||
export const TARGET_PERIOD_TYPES = [
|
||||
'day',
|
||||
'week',
|
||||
'month',
|
||||
'quarter',
|
||||
'half_year',
|
||||
'nine_month',
|
||||
'ninety_day',
|
||||
'year',
|
||||
] as const;
|
||||
export type TargetPeriodType = (typeof TARGET_PERIOD_TYPES)[number];
|
||||
|
||||
/** What is being planned. */
|
||||
@@ -31,9 +49,13 @@ export const TARGET_DIMENSION_LABELS: Record<TargetDimension, string> = {
|
||||
};
|
||||
|
||||
export const TARGET_PERIOD_LABELS: Record<TargetPeriodType, string> = {
|
||||
day: 'Daily',
|
||||
week: 'Weekly',
|
||||
month: 'Monthly',
|
||||
quarter: 'Quarterly',
|
||||
half_year: 'Half-yearly',
|
||||
nine_month: 'Nine-monthly',
|
||||
ninety_day: '90-day',
|
||||
year: 'Yearly',
|
||||
};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user