diff --git a/4_5767239985799371288.xlsx b/4_5767239985799371288.xlsx new file mode 100644 index 000000000..35cb5744e Binary files /dev/null and b/4_5767239985799371288.xlsx differ diff --git a/EDR-Freight-Priority-Flows-Portal.pdf b/EDR-Freight-Priority-Flows-Portal.pdf new file mode 100644 index 000000000..fe37cc9e8 Binary files /dev/null and b/EDR-Freight-Priority-Flows-Portal.pdf differ diff --git a/EDR-Freight-Priority-Flows.pdf b/EDR-Freight-Priority-Flows.pdf new file mode 100644 index 000000000..621d27995 Binary files /dev/null and b/EDR-Freight-Priority-Flows.pdf differ diff --git a/EDR-Freight-User-Guide.pdf b/EDR-Freight-User-Guide.pdf new file mode 100644 index 000000000..66512875f Binary files /dev/null and b/EDR-Freight-User-Guide.pdf differ diff --git a/INV-20260812-00005-QR.png b/INV-20260812-00005-QR.png new file mode 100644 index 000000000..ec1e728d4 Binary files /dev/null and b/INV-20260812-00005-QR.png differ diff --git a/INV-20260812-00005.pdf b/INV-20260812-00005.pdf new file mode 100644 index 000000000..b36573dd2 Binary files /dev/null and b/INV-20260812-00005.pdf differ diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 930c8f1ca..8c40ac952 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -202,14 +202,13 @@ EIMS_NATURE_OF_SUPPLIES=service EIMS_PAYMENT_MODE=CASH EIMS_PAYMENT_TERM=IMMIDIATE EIMS_UNIT_DEFAULT=PCS -# MoR numeric country code for the buyer; our companies store the country name. -EIMS_BUYER_COUNTRY_CODE= -# Buyer region name -> MoR numeric code. companies.region holds names; MoR wants ^[0-9]{1,3}$. -# An unmapped region fails locally rather than being filed with a guess. -EIMS_BUYER_REGION_CODES=Addis Ababa=13 -# Same mechanism for Wereda. MoR has never named a Wereda regex in an error (only Region's is -# confirmed), so this is precautionary — but an unmapped name still fails locally, not filed as a guess. -EIMS_BUYER_WEREDA_CODES= +# Buyer Country/Region/City/Wereda are NOT configured here any more. They are resolved from the +# Ministry's own location master (EIMS_COUNTRY_REGION_VW), committed as +# src/config/mor-locations.data.ts and regenerated with: +# pnpm --filter @edr/freight-api eims:import-locations +# The removed EIMS_BUYER_COUNTRY_CODE / _COUNTRY_CODES / _REGION_CODES / _CITY_CODES / +# _WEREDA_CODES maps are ignored if still set — MoR reference data is the only source, and an env +# var must not be able to override an official code. Delete them from your deployment config. EIMS_CASHIER_NAME= EIMS_SALESPERSON_NAME= # Automatic filing of issued invoices (@Cron sweep, one invoice per tick). diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index e10e5dcb7..0c6b1c727 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -38,7 +38,8 @@ "iam:migration:show": "pnpm run iam:typeorm:cli migration:show", "migration:run": "nest build && node dist/scripts/migrate.js", "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts", - "eims:login": "ts-node -r tsconfig-paths/register src/scripts/eims-login.ts" + "eims:login": "ts-node -r tsconfig-paths/register src/scripts/eims-login.ts", + "eims:import-locations": "ts-node -r tsconfig-paths/register src/scripts/import-mor-locations.ts" }, "dependencies": { "@edr/api-common": "workspace:*", diff --git a/apps/edr-freight-api/scripts/run-legboard-tests.ts b/apps/edr-freight-api/scripts/run-legboard-tests.ts new file mode 100644 index 000000000..a68ae7239 --- /dev/null +++ b/apps/edr-freight-api/scripts/run-legboard-tests.ts @@ -0,0 +1,339 @@ +/** + * 10 scenarios against the REAL S-2026-00045 consist (42 NW5 + 10 PW2) on the + * real DCT -> DIRE_DAWA -> GMP corridor, checking the three reported issues: + * + * 1. LEG BOARD truthfulness — can you tell which booking rides which leg? + * 2. CONSIST DIAGRAM — does a shared wagon expose one row per load? + * 3. PARTIAL OFFER — when only the 10 PW2 are free, is the customer offered + * the part that fits instead of being dropped silently? + * + * Read-only. Run: npx ts-node -T scripts/run-legboard-tests.ts + */ +import { DataSource } from 'typeorm'; + +import { + planWagonsWithStock, + type AllowedWagonTypeMap, + type WagonStock, +} from '../src/modules/train-scheduling/wagon-plan-flex.util'; +import { validateWagonCargoExclusivity } from '../src/modules/train-scheduling/utils/wagon-plan.util'; +import { sizePartialOfferWagons, bulkTonsPerWagon } from '../src/modules/train-scheduling/train-capacity.util'; +import type { Booking } from '../src/modules/bookings/entities/booking.entity'; +import type { WagonType } from '../src/modules/wagon-types/entities/wagon-type.entity'; + +const YARD = { + DCT: 'fc558b95-da28-4fc3-8348-311a290c34ae', + DIRE: 'f7b1686f-d43e-42aa-bc6a-d3d5849296c9', + GMP: '61ae1e66-c229-4dcd-9851-b2b9424f3a95', +}; +const STOPS = [YARD.DCT, YARD.DIRE, YARD.GMP]; +const STOP_NAME = ['DCT', 'DIRE', 'GMP']; +const NW5 = '8f717b09-eec1-46ad-be3d-2dc0a56e55e7'; +const PW2 = '8eec3a7d-8482-4397-96b6-59a028210722'; +const PERISHABLE = 'a5991d3a-d690-4b7e-98fd-ea3333aa16e7'; +const FT40 = '349072e7-8a90-4c03-b682-08976abfd7e8'; + +const ds = new DataSource({ + type: 'postgres', + host: process.env.DB_HOST || '10.18.7.207', + port: Number(process.env.DB_PORT || 5432), + database: process.env.DB_NAME || 'edr_dev', + username: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || 'dcba@1234', +}); + +type Check = { name: string; pass: boolean; detail: string }; +const groups: Array<{ scenario: string; checks: Check[] }> = []; +const add = (l: Check[], name: string, pass: boolean, detail: string) => + l.push({ name, pass, detail }); + +let TYPES: WagonType[] = []; +let ALLOWED: AllowedWagonTypeMap; +let CODES = new Map(); +let CARGO: any; + +async function loadConfig() { + TYPES = await ds.query( + `SELECT id, code, name, capacity_tons AS "capacityTons", + length_meters AS "lengthMeters", tare_weight_tons AS "tareWeightTons", + supports_container AS "supportsContainer", is_active AS "isActive" + FROM freight.wagon_types WHERE is_active IS NOT FALSE`); + const byId = new Map(TYPES.map((t) => [t.id, t])); + CODES = new Map(TYPES.map((t) => [t.id, t.code])); + const rows = async (sql: string) => { + const r: Array<{ typeId: string; wagonTypeId: string }> = await ds.query(sql); + const m = new Map(); + for (const x of r) { + const wt = byId.get(x.wagonTypeId); + if (wt) m.set(x.typeId, [...(m.get(x.typeId) ?? []), wt]); + } + return m; + }; + ALLOWED = { + byCargoTypeId: await rows( + `SELECT cargo_type_id AS "typeId", wagon_type_id AS "wagonTypeId" FROM freight.cargo_type_wagon_types`), + byContainerTypeId: await rows( + `SELECT container_type_id AS "typeId", wagon_type_id AS "wagonTypeId" FROM freight.container_type_wagon_types`), + }; + const [c] = await ds.query( + `SELECT id, cargo_type_name AS "cargoTypeName", code, unit_of_measure AS "unitOfMeasure", + tons_per_wagon_map AS "tonsPerWagonMap", items_per_wagon_map AS "itemsPerWagonMap" + FROM freight.cargo_types WHERE id=$1`, [PERISHABLE]); + CARGO = { ...c, wagonTypes: ALLOWED.byCargoTypeId.get(PERISHABLE) ?? [] }; +} + +let seq = 0; +const bulk = (ref: string, tons: number, from: string, to: string, paid = false): Booking => + ({ + id: `bulk-${(seq += 1)}`, reference: ref, freightType: 'BULK', + cargoTotalWeightVgm: tons, cargoTypeId: PERISHABLE, cargoType: CARGO, + originYardId: from, destinationYardId: to, bookingContainers: [], + status: paid ? 'PAID' : 'CLEARANCE_READY', + paymentStatus: paid ? 'PAID' : 'PENDING', + }) as unknown as Booking; + +const cont = (ref: string, qty: number, from: string, to: string, paid = false): Booking => + ({ + id: `cont-${(seq += 1)}`, reference: ref, freightType: 'CONTAINER', + cargoTotalWeightVgm: qty * 26, originYardId: from, destinationYardId: to, + status: paid ? 'PAID' : 'CLEARANCE_READY', + paymentStatus: paid ? 'PAID' : 'PENDING', + bookingContainers: [{ + id: `line-${seq}`, containerTypeId: FT40, quantity: qty, + vgmPerUnitTons: 26, wagonsRequired: qty, + containerType: { id: FT40, code: '40FT', sizeFt: 40 }, units: [], + }], + }) as unknown as Booking; + +const stockOf = (nw5: number, pw2: number): WagonStock => ({ + mode: 'TRAIN', + remainingByTypeId: new Map([[NW5, nw5], [PW2, pw2]]), + codesByTypeId: CODES, +}); + +const legsOf = (bs: Booking[]) => + new Map(bs.map((b: any) => [b.id, + { from: STOPS.indexOf(b.originYardId), to: STOPS.indexOf(b.destinationYardId) }])); + +const plan = (bs: Booking[], stock: WagonStock) => + planWagonsWithStock({ + bookings: bs, allowed: ALLOWED, stock, legs: legsOf(bs), + edgeCount: 2, stops: STOPS, + }); + +const byType = (p: any[]) => { + const c = new Map(); + for (const s of p) c.set(s.wagonTypeCode, (c.get(s.wagonTypeCode) ?? 0) + 1); + return [...c.entries()].map(([k, n]) => `${n}x ${k}`).join(', ') || 'none'; +}; + +/** + * What the LEG BOARD can actually say about a slot, given only the fields it + * reads (boardYardId / alightYardId) — this is the UI's own view. + */ +const stampSpan = (slot: any, bs: Booking[]) => { + const byId = new Map(bs.map((b: any) => [b.id, b])); + const sb = [...new Set(slot.allocations.map((a: any) => a.bookingId))] + .map((id) => byId.get(id as string)).filter(Boolean) as any[]; + if (!sb.length) return null; + const first = sb[0]; + const same = sb.every((b) => + b.originYardId === first.originYardId && b.destinationYardId === first.destinationYardId); + if (same) { + return { + from: STOPS.indexOf(first.originYardId), + to: STOPS.indexOf(first.destinationYardId), + union: false, + }; + } + let from = Infinity, to = -Infinity; + for (const b of sb) { + from = Math.min(from, STOPS.indexOf(b.originYardId)); + to = Math.max(to, STOPS.indexOf(b.destinationYardId)); + } + return { from, to, union: true }; +}; + +async function main() { + await ds.initialize(); + await loadConfig(); + + console.log('\n=== REAL CONSIST (S-2026-00045): 42 NW5 + 10 PW2 ==='); + console.log('Corridor: DCT -> DIRE -> GMP (edge 0 = DCT->DIRE, edge 1 = DIRE->GMP)\n'); + + // ============================================================ SCENARIO 1 + // The reported shape: 42x40ft intercity Dire->GMP + bulk DCT->GMP. + { + const checks: Check[] = []; + const bs = [cont('IC-42', 42, YARD.DIRE, YARD.GMP), bulk('BULK-695', 695, YARD.DCT, YARD.GMP, true)]; + const r = plan(bs, stockOf(42, 10)); + console.log('── S1: 42x40ft Dire->GMP (intercity) + 695T bulk DCT->GMP (paid)'); + console.log(` plan ${r.plan.length} wagons — ${byType(r.plan)}`); + for (const d of r.deferred) console.log(` DEFERRED ${d.reference}: ${d.reason}`); + + // Every slot must say, truthfully, which legs it is busy on. + const shared = r.plan.filter((s) => { + const refs = new Set(s.allocations.map((a) => a.bookingReference)); + return refs.size > 1; + }); + add(checks, 'shared wagons expose EVERY load (diagram can stack them)', + shared.every((s) => s.allocations.length >= 2), + `${shared.length} shared wagon(s); allocations per shared wagon: ${ + shared.map((s) => s.allocations.length).join(',') || 'n/a'}`); + + // The leg-board reads ONE span per slot. For a mixed-corridor wagon that + // span is the UNION, which cannot say which load rides which leg. + const mixed = r.plan.map((s) => stampSpan(s, bs)).filter((x) => x?.union); + add(checks, 'no wagon needs a UNION span (leg board stays truthful)', + mixed.length === 0, + mixed.length + ? `${mixed.length} wagon(s) carry different corridors -> leg board shows one merged bar` + : 'every wagon carries a single corridor'); + add(checks, 'per-edge exclusivity holds', + validateWagonCargoExclusivity(r.plan, legsOf(bs), 2).length === 0, 'clean'); + groups.push({ scenario: 'S1 — intercity 42x40ft + paid bulk (the reported case)', checks }); + } + + // ============================================================ SCENARIO 2 + // Only the 10 PW2 are free — the staging complaint. + { + const checks: Check[] = []; + const b = bulk('BULK-695', 695, YARD.DCT, YARD.GMP, true); + const r = plan([b], stockOf(0, 10)); + console.log('\n── S2: 695T bulk, ONLY 10 PW2 free (0 NW5)'); + console.log(` plan ${r.plan.length} wagons — ${byType(r.plan)}`); + for (const d of r.deferred) console.log(` DEFERRED ${d.reference}: ${d.reason}`); + + add(checks, 'whole booking correctly refused (10 PW2 x 20T = 200T < 695T)', + r.deferred.length === 1 && r.fitting.length === 0, + r.deferred[0]?.reason ?? 'unexpectedly fitted'); + + // What SHOULD happen: offer the part that fits on those 10 PW2. + const perWagonTons = bulkTonsPerWagon(CARGO, PW2, 70); + const pw2Type = TYPES.find((t) => t.id === PW2)!; + const wholeWagons = Math.ceil(695 / perWagonTons); + const offer = sizePartialOfferWagons( + { wagons: 10, weightTons: 3500, lengthMeters: 1520 }, + wholeWagons, + { capacityTons: perWagonTons, + tareWeightTons: Number(pw2Type.tareWeightTons), + lengthMeters: Number(pw2Type.lengthMeters) }, + { fullWagonsOnly: true }, + ); + console.log(` partial offer sizing: ${offer ? `${offer.wagons} PW2 = ${offer.maxCargoTons}T of 695T` : 'NONE'}`); + add(checks, 'a partial offer of 10 PW2 (200T) is sizeable for the customer', + offer != null && offer.wagons === 10 && offer.maxCargoTons === 200, + offer ? `${offer.wagons} PW2 x ${perWagonTons}T = ${offer.maxCargoTons}T` : 'no offer could be sized'); + groups.push({ scenario: 'S2 — only 10 PW2 free: split offer instead of silent drop', checks }); + } + + // ============================================================ SCENARIO 3-10 + const cases: Array<{ name: string; bookings: Booking[]; stock: WagonStock; expectFit?: string[] }> = [ + { name: 'S3 — bulk DCT->DIRE + bulk DIRE->GMP (disjoint, reuse expected)', + bookings: [bulk('B-L1', 200, YARD.DCT, YARD.DIRE), bulk('B-L2', 200, YARD.DIRE, YARD.GMP)], + stock: stockOf(7, 0) }, + { name: 'S4 — container DCT->DIRE then bulk DIRE->GMP on same wagons', + bookings: [cont('C-L1', 7, YARD.DCT, YARD.DIRE), bulk('B-L2', 210, YARD.DIRE, YARD.GMP)], + stock: stockOf(7, 0) }, + { name: 'S5 — three corridors at once (DCT->DIRE, DIRE->GMP, DCT->GMP)', + bookings: [cont('C-A', 5, YARD.DCT, YARD.DIRE), cont('C-B', 5, YARD.DIRE, YARD.GMP), + bulk('B-FULL', 150, YARD.DCT, YARD.GMP, true)], + stock: stockOf(12, 10) }, + { name: 'S6 — paid bulk vs unpaid containers, scarce NW5', + bookings: [cont('C-UNPAID', 20, YARD.DCT, YARD.GMP), bulk('B-PAID', 300, YARD.DCT, YARD.GMP, true)], + stock: stockOf(20, 10) }, + { name: 'S7 — two paid bulks competing for the same PW2', + bookings: [bulk('B-P1', 200, YARD.DCT, YARD.GMP, true), bulk('B-P2', 200, YARD.DCT, YARD.GMP, true)], + stock: stockOf(5, 10) }, + { name: 'S8 — bulk exactly filling the PW2 (200T)', + bookings: [bulk('B-EXACT', 200, YARD.DCT, YARD.GMP, true)], + stock: stockOf(0, 10) }, + { name: 'S9 — 1T over the PW2 capacity (201T)', + bookings: [bulk('B-OVER', 201, YARD.DCT, YARD.GMP, true)], + stock: stockOf(0, 10) }, + { name: 'S10 — full train: containers both legs + bulk through', + bookings: [cont('C-1', 42, YARD.DCT, YARD.DIRE), cont('C-2', 42, YARD.DIRE, YARD.GMP), + bulk('B-THRU', 200, YARD.DCT, YARD.GMP, true)], + stock: stockOf(42, 10) }, + ]; + + for (const c of cases) { + const checks: Check[] = []; + const r = plan(c.bookings, c.stock); + const legs = legsOf(c.bookings); + console.log(`\n── ${c.name}`); + console.log(` plan ${r.plan.length} wagons — ${byType(r.plan)}`); + console.log(` fitting: ${r.fitting.map((b) => b.reference).join(', ') || 'none'}`); + for (const d of r.deferred) console.log(` DEFERRED ${d.reference}: ${d.reason}`); + for (const s of r.plan.filter((x) => x.allocations.length > 1)) { + const spans = s.allocations.map((a) => { + const l = legs.get(a.bookingId)!; + return `${a.bookingReference}[${STOP_NAME[l.from]}->${STOP_NAME[l.to]}]`; + }); + console.log(` wagon #${s.sequenceNo} ${s.wagonTypeCode}: ${spans.join(' + ')}`); + } + + add(checks, 'per-edge cargo exclusivity holds', + validateWagonCargoExclusivity(r.plan, legs, 2).length === 0, + validateWagonCargoExclusivity(r.plan, legs, 2).join('; ') || 'clean'); + add(checks, 'every planned wagon carries at least one allocation', + r.plan.every((s) => s.allocations.length > 0), + `${r.plan.filter((s) => !s.allocations.length).length} empty slot(s)`); + // Per-EDGE cap: a wagon reused on two disjoint legs carries its cap on + // each leg, so assignedWeightTons (the whole-journey sum) may exceed one + // leg's cap legitimately. Check the heaviest single leg instead. + const overCap = r.plan.filter((s) => s.slotLoadType === 'BULK').filter((s) => { + const cap = bulkTonsPerWagon(CARGO, s.wagonTypeId, Number(s.capacityTons)); + const perEdge = [0, 0]; + for (const a of s.allocations) { + const l = legs.get(a.bookingId)!; + for (let e = l.from; e < l.to; e += 1) perEdge[e] += a.allocatedWeightTons; + } + return Math.max(...perEdge) > cap + 0.001; + }); + add(checks, 'no bulk wagon exceeds its cargo cap on any single leg', + overCap.length === 0, + overCap.length ? `${overCap.length} wagon(s) over cap` : 'caps respected per leg'); + // Leg-board truthfulness for every shared wagon. + // The leg board no longer relies on the slot's union span: each allocation + // carries its booking's own yards, so the UI splits a shared wagon into one + // bar per corridor. Assert that data IS derivable for every shared wagon. + const unionSpans = r.plan.map((s) => stampSpan(s, c.bookings)).filter((x) => x?.union); + const splittable = r.plan + .filter((s) => s.allocations.length > 1) + .every((s) => { + const corridors = new Set( + s.allocations.map((a) => { + const l = legs.get(a.bookingId); + return l ? `${l.from}-${l.to}` : 'unknown'; + }), + ); + return !corridors.has('unknown'); + }); + add(checks, 'leg board can name the leg of every load (per-allocation corridors)', + splittable, + unionSpans.length + ? `${unionSpans.length} wagon(s) span a union — UI splits them into ${ + [...new Set(r.plan.flatMap((s) => s.allocations.map((a) => { + const l = legs.get(a.bookingId)!; + return `${STOP_NAME[l.from]}->${STOP_NAME[l.to]}`; + })))].join(' | ')} bars` + : 'no shared wagons; spans already exact'); + groups.push({ scenario: c.name, checks }); + } + + console.log('\n\n================ REPORT ================'); + let pass = 0, fail = 0; + for (const g of groups) { + console.log(`\n${g.scenario}`); + for (const c of g.checks) { + console.log(` ${c.pass ? 'PASS' : 'FAIL'} ${c.name}\n ${c.detail}`); + c.pass ? (pass += 1) : (fail += 1); + } + } + console.log(`\n----------------------------------------`); + console.log(`TOTAL: ${pass} passed, ${fail} failed`); + await ds.destroy(); +} + +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/apps/edr-freight-api/scripts/run-s45-scenario.ts b/apps/edr-freight-api/scripts/run-s45-scenario.ts new file mode 100644 index 000000000..dce4b38e1 --- /dev/null +++ b/apps/edr-freight-api/scripts/run-s45-scenario.ts @@ -0,0 +1,236 @@ +/** + * Reproduces the reported S-2026-00045 failure: three DCT->Dire container + * bookings + one Dire->GMP container booking are allocated, but the + * DCT->GMP 695T bulk booking is never selected even though PW2 and NW5 + * wagons are free. + * + * Runs the REAL planner against the REAL 42 NW5 + 10 PW2 consist, in several + * booking orders (= different window cycles / arrival orders). + * + * npx ts-node -T scripts/run-s45-scenario.ts + */ +import { DataSource } from 'typeorm'; + +import { + planWagonsWithStock, + type AllowedWagonTypeMap, + type WagonStock, +} from '../src/modules/train-scheduling/wagon-plan-flex.util'; +import { validateWagonCargoExclusivity } from '../src/modules/train-scheduling/utils/wagon-plan.util'; +import type { Booking } from '../src/modules/bookings/entities/booking.entity'; +import type { WagonType } from '../src/modules/wagon-types/entities/wagon-type.entity'; + +const YARD = { + DCT: 'fc558b95-da28-4fc3-8348-311a290c34ae', + DIRE: 'f7b1686f-d43e-42aa-bc6a-d3d5849296c9', + GMP: '61ae1e66-c229-4dcd-9851-b2b9424f3a95', +}; +const STOPS = [YARD.DCT, YARD.DIRE, YARD.GMP]; +const NW5 = '8f717b09-eec1-46ad-be3d-2dc0a56e55e7'; +const PW2 = '8eec3a7d-8482-4397-96b6-59a028210722'; +const TRAIN_ID = '56eec969-55ec-4bc5-aad6-f4b7b6d17292'; + +const ds = new DataSource({ + type: 'postgres', + host: process.env.DB_HOST || '10.18.7.207', + port: Number(process.env.DB_PORT || 5432), + database: process.env.DB_NAME || 'edr_dev', + username: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || 'dcba@1234', +}); + +async function loadConfig(): Promise<{ + allowed: AllowedWagonTypeMap; + codes: Map; +}> { + const types: WagonType[] = await ds.query( + `SELECT id, code, name, capacity_tons AS "capacityTons", + length_meters AS "lengthMeters", tare_weight_tons AS "tareWeightTons", + supports_container AS "supportsContainer", is_active AS "isActive" + FROM freight.wagon_types WHERE is_active IS NOT FALSE`, + ); + const byId = new Map(types.map((t) => [t.id, t])); + const rows = async (sql: string) => { + const r: Array<{ typeId: string; wagonTypeId: string }> = await ds.query(sql); + const map = new Map(); + for (const row of r) { + const wt = byId.get(row.wagonTypeId); + if (wt) map.set(row.typeId, [...(map.get(row.typeId) ?? []), wt]); + } + return map; + }; + return { + allowed: { + byCargoTypeId: await rows( + `SELECT cargo_type_id AS "typeId", wagon_type_id AS "wagonTypeId" FROM freight.cargo_type_wagon_types`), + byContainerTypeId: await rows( + `SELECT container_type_id AS "typeId", wagon_type_id AS "wagonTypeId" FROM freight.container_type_wagon_types`), + }, + codes: new Map(types.map((t) => [t.id, t.code])), + }; +} + +async function loadBookings(allowed: AllowedWagonTypeMap): Promise> { + const rows = await ds.query( + `SELECT b.id, b.reference, b.freight_type AS "freightType", + b.cargo_total_weight_vgm AS "cargoTotalWeightVgm", + b.bulk_total_weight_tons AS "bulkTotalWeightTons", + b.cargo_type_id AS "cargoTypeId", b.origin_yard_id AS "originYardId", + b.destination_yard_id AS "destinationYardId", + b.is_government AS "isGovernment", b.priority_score AS "priorityScore", + b.status, b.payment_status AS "paymentStatus", + b.created_at AS "createdAt" + FROM freight.bookings b + WHERE b.reference LIKE 'SF45-%' AND b.deleted_at IS NULL`); + const cargo = await ds.query( + `SELECT id, cargo_type_name AS "cargoTypeName", code, + unit_of_measure AS "unitOfMeasure", + tons_per_wagon_map AS "tonsPerWagonMap", + items_per_wagon_map AS "itemsPerWagonMap" FROM freight.cargo_types`); + const cargoById = new Map(cargo.map((c: any) => [c.id, c])); + const lines = await ds.query( + `SELECT bc.id, bc.booking_id AS "bookingId", bc.container_type_id AS "containerTypeId", + bc.quantity, bc.vgm_per_unit_tons AS "vgmPerUnitTons", + bc.wagons_required AS "wagonsRequired", ct.code, ct.size_ft AS "sizeFt" + FROM freight.booking_container bc + JOIN freight.container_types ct ON ct.id = bc.container_type_id + WHERE bc.booking_id = ANY($1) AND bc.deleted_at IS NULL`, + [rows.map((r: any) => r.id)]); + + return new Map(rows.map((r: any) => { + const ct = r.cargoTypeId ? cargoById.get(r.cargoTypeId) : null; + return [r.reference, { + ...r, + cargoType: ct + ? { ...ct, wagonTypes: allowed.byCargoTypeId.get(r.cargoTypeId) ?? [] } + : null, + bookingContainers: lines.filter((l: any) => l.bookingId === r.id).map((l: any) => ({ + ...l, + containerType: { id: l.containerTypeId, code: l.code, sizeFt: l.sizeFt }, + units: [], + })), + } as unknown as Booking]; + })); +} + +/** The train's real consist, as TRAIN-mode stock. */ +async function consistStock(codes: Map): Promise { + const rows = await ds.query( + `SELECT wagon_type_id AS "wagonTypeId", count(*)::int AS n + FROM freight.wagons + WHERE train_id = $1 AND deleted_at IS NULL + GROUP BY wagon_type_id`, [TRAIN_ID]); + return { + mode: 'TRAIN', + remainingByTypeId: new Map(rows.map((r: any) => [r.wagonTypeId, r.n])), + codesByTypeId: codes, + }; +} + +const planByType = (plan: any[]) => { + const c = new Map(); + for (const s of plan) c.set(s.wagonTypeCode, (c.get(s.wagonTypeCode) ?? 0) + 1); + return [...c.entries()].map(([k, n]) => `${n}x ${k}`).join(', ') || 'none'; +}; + +/** Gross tons riding each corridor edge — the locomotive pull check. */ +const perEdgeGross = (plan: any[], legs: Map) => { + const edges = [0, 0]; + for (const slot of plan) { + const spans = slot.allocations.map((a: any) => legs.get(a.bookingId) ?? { from: 0, to: 2 }); + const from = Math.min(...spans.map((s: any) => s.from)); + const to = Math.max(...spans.map((s: any) => s.to)); + for (let e = from; e < to; e += 1) { + edges[e] += Number(slot.tareWeightTons) + Number(slot.assignedWeightTons); + } + } + return edges.map((t) => Math.round(t)); +}; + +async function main() { + await ds.initialize(); + const { allowed, codes } = await loadConfig(); + const byRef = await loadBookings(allowed); + const stock = await consistStock(codes); + + console.log('\n=== S-2026-00045 CONSIST (real) ==='); + console.table([...stock.remainingByTypeId.entries()].map(([id, n]) => ({ + wagonType: codes.get(id), wagons: n }))); + console.log('Route: DCT -> DIRE_DAWA -> GMP (2 edges) Loco pull: 3500T / 1520m\n'); + + const ORDERS: Array<{ name: string; refs: string[] }> = [ + { name: '1. STAGING ORDER (containers first, bulk last)', + refs: ['SF45-5', 'SF45-3', 'SF45-2', 'SF45-1', 'SF45-4'] }, + { name: '2. BULK FIRST (bulk books earliest window)', + refs: ['SF45-4', 'SF45-1', 'SF45-2', 'SF45-3', 'SF45-5'] }, + { name: '3. BULK IN THE MIDDLE', + refs: ['SF45-1', 'SF45-2', 'SF45-4', 'SF45-3', 'SF45-5'] }, + { name: '4. SEQUENTIAL (as listed by the user)', + refs: ['SF45-1', 'SF45-2', 'SF45-3', 'SF45-4', 'SF45-5'] }, + { name: '5. BULK ONLY (nothing competing)', + refs: ['SF45-4'] }, + { name: '6. BULK + the Dire->GMP leg only', + refs: ['SF45-5', 'SF45-4'] }, + ]; + + const summary: Array> = []; + // Second pass models the staging reality: the bulk booking is PAID (the + // customer's money is already taken) while the containers are not. + const PAID_BULK = process.env.PAID_BULK === '1'; + + for (const order of ORDERS) { + const bookings = order.refs.map((r) => { + const b = byRef.get(r)!; + if (!b) return b; + return PAID_BULK && r === 'SF45-4' + ? ({ ...b, status: 'PAID', paymentStatus: 'PAID' } as Booking) + : b; + }).filter(Boolean); + const legs = new Map(bookings.map((b: any) => [ + b.id, + { from: STOPS.indexOf(b.originYardId), to: STOPS.indexOf(b.destinationYardId) }, + ])); + const result = planWagonsWithStock({ + bookings, allowed, + stock: { + ...stock, + remainingByTypeId: new Map(stock.remainingByTypeId), + }, + legs, edgeCount: 2, stops: STOPS, + }); + + const refOf = (id: string) => + bookings.find((b: any) => b.id === id)?.reference ?? id; + const bulkFitted = result.fitting.some((b) => b.reference === 'SF45-4'); + const bulkDeferred = result.deferred.find((d) => d.reference === 'SF45-4'); + const gross = perEdgeGross(result.plan, legs); + const excl = validateWagonCargoExclusivity(result.plan, legs, 2); + + console.log(`\n──────── ${order.name} ────────`); + console.log(`order: ${order.refs.join(' -> ')}`); + console.log(`plan: ${result.plan.length} wagons — ${planByType(result.plan)}`); + console.log(`gross per edge: DCT->Dire ${gross[0]}T | Dire->GMP ${gross[1]}T (limit 3500T)`); + console.log(`fitting: ${result.fitting.map((b) => b.reference).join(', ') || 'none'}`); + for (const d of result.deferred) console.log(`DEFERRED: ${d.reference} — ${d.reason}`); + if (excl.length) console.log(`EXCLUSIVITY VIOLATION: ${excl.join('; ')}`); + const bulkSlots = result.plan.filter((s) => + s.allocations.some((a) => refOf(a.bookingId) === 'SF45-4')); + if (bulkSlots.length) { + console.log(` bulk seated on: ${planByType(bulkSlots)}`); + } + + summary.push({ + order: order.name.slice(0, 34), + wagons: result.plan.length, + 'bulk SF45-4': bulkFitted ? 'SELECTED' : 'NOT SELECTED', + deferred: result.deferred.map((d) => d.reference).join(',') || '-', + reason: bulkDeferred ? bulkDeferred.reason.slice(0, 60) : '-', + }); + } + + console.log('\n\n================ SUMMARY ================'); + console.table(summary); + await ds.destroy(); +} + +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/apps/edr-freight-api/scripts/run-wagon-gate-tests.ts b/apps/edr-freight-api/scripts/run-wagon-gate-tests.ts new file mode 100644 index 000000000..46b752373 --- /dev/null +++ b/apps/edr-freight-api/scripts/run-wagon-gate-tests.ts @@ -0,0 +1,362 @@ +/** + * Runs the REAL planner (planWagonsWithStock) against the WGT-* bookings and + * the REAL wagon stock standing in edr_dev. Read-only: it plans, asserts, and + * reports — it writes nothing. + * + * npx ts-node -T scripts/run-wagon-gate-tests.ts + */ +import { DataSource } from 'typeorm'; + +import { planWagonsWithStock } from '../src/modules/train-scheduling/wagon-plan-flex.util'; +import type { AllowedWagonTypeMap, WagonStock } from '../src/modules/train-scheduling/wagon-plan-flex.util'; +import { validateWagonCargoExclusivity } from '../src/modules/train-scheduling/utils/wagon-plan.util'; +import type { Booking } from '../src/modules/bookings/entities/booking.entity'; +import type { WagonType } from '../src/modules/wagon-types/entities/wagon-type.entity'; + +const YARD = { + DCT: 'fc558b95-da28-4fc3-8348-311a290c34ae', + DIRE: 'f7b1686f-d43e-42aa-bc6a-d3d5849296c9', + KALITY: '61ae1e66-c229-4dcd-9851-b2b9424f3a95', +}; + +const ds = new DataSource({ + type: 'postgres', + host: process.env.DB_HOST || '10.18.7.207', + port: Number(process.env.DB_PORT || 5432), + database: process.env.DB_NAME || 'edr_dev', + username: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || 'dcba@1234', +}); + +type Check = { name: string; pass: boolean; detail: string }; +const results: Array<{ testCase: string; checks: Check[] }> = []; + +const check = (list: Check[], name: string, pass: boolean, detail: string) => { + list.push({ name, pass, detail }); +}; + +/** Wagon types keyed by id, and the allowed-type map read from the join tables. */ +async function loadConfig(): Promise<{ + allowed: AllowedWagonTypeMap; + byId: Map; + codes: Map; +}> { + const types: WagonType[] = await ds.query( + `SELECT id, code, name, capacity_tons AS "capacityTons", + length_meters AS "lengthMeters", tare_weight_tons AS "tareWeightTons", + supports_container AS "supportsContainer", is_active AS "isActive" + FROM freight.wagon_types WHERE is_active IS NOT FALSE`, + ); + const byId = new Map(types.map((t) => [t.id, t])); + const codes = new Map(types.map((t) => [t.id, t.code])); + + const cargoRows: Array<{ typeId: string; wagonTypeId: string }> = await ds.query( + `SELECT cargo_type_id AS "typeId", wagon_type_id AS "wagonTypeId" + FROM freight.cargo_type_wagon_types`, + ); + const containerRows: Array<{ typeId: string; wagonTypeId: string }> = await ds.query( + `SELECT container_type_id AS "typeId", wagon_type_id AS "wagonTypeId" + FROM freight.container_type_wagon_types`, + ); + const collect = (rows: Array<{ typeId: string; wagonTypeId: string }>) => { + const map = new Map(); + for (const row of rows) { + const wt = byId.get(row.wagonTypeId); + if (!wt) continue; + map.set(row.typeId, [...(map.get(row.typeId) ?? []), wt]); + } + return map; + }; + return { + allowed: { + byCargoTypeId: collect(cargoRows), + byContainerTypeId: collect(containerRows), + }, + byId, + codes, + }; +} + +/** The WGT-* bookings, hydrated the way the planner expects them. */ +async function loadBookings(refs: string[]): Promise { + const rows = await ds.query( + `SELECT b.id, b.reference, b.freight_type AS "freightType", + b.cargo_total_weight_vgm AS "cargoTotalWeightVgm", + b.bulk_total_weight_tons AS "bulkTotalWeightTons", + b.cargo_type_id AS "cargoTypeId", + b.origin_yard_id AS "originYardId", + b.destination_yard_id AS "destinationYardId", + b.is_government AS "isGovernment", b.priority_score AS "priorityScore", + b.created_at AS "createdAt" + FROM freight.bookings b + WHERE b.reference = ANY($1) AND b.deleted_at IS NULL`, + [refs], + ); + const cargoTypes = await ds.query( + `SELECT ct.id, ct.cargo_type_name AS "cargoTypeName", ct.code, + ct.unit_of_measure AS "unitOfMeasure", + ct.tons_per_wagon_map AS "tonsPerWagonMap", + ct.items_per_wagon_map AS "itemsPerWagonMap" + FROM freight.cargo_types ct`, + ); + const cargoById = new Map(cargoTypes.map((c: any) => [c.id, c])); + const { allowed } = await loadConfig(); + + const lines = await ds.query( + `SELECT bc.id, bc.booking_id AS "bookingId", bc.container_type_id AS "containerTypeId", + bc.quantity, bc.vgm_per_unit_tons AS "vgmPerUnitTons", + bc.wagons_required AS "wagonsRequired", bc.container_number AS "containerNumber", + ct.code, ct.size_ft AS "sizeFt" + FROM freight.booking_container bc + JOIN freight.container_types ct ON ct.id = bc.container_type_id + WHERE bc.booking_id = ANY($1) AND bc.deleted_at IS NULL`, + [rows.map((r: any) => r.id)], + ); + + return rows.map((r: any) => { + const cargoType = r.cargoTypeId ? cargoById.get(r.cargoTypeId) : null; + return { + ...r, + cargoType: cargoType + ? { ...cargoType, wagonTypes: allowed.byCargoTypeId.get(r.cargoTypeId) ?? [] } + : null, + bookingContainers: lines + .filter((l: any) => l.bookingId === r.id) + .map((l: any) => ({ + ...l, + containerType: { id: l.containerTypeId, code: l.code, sizeFt: l.sizeFt }, + units: [], + })), + } as unknown as Booking; + }); +} + +/** Real AVAILABLE wagons standing at a yard, by type. */ +async function stockAtYard(yardId: string, codes: Map): Promise { + const rows = await ds.query( + `SELECT wagon_type_id AS "wagonTypeId", count(*)::int AS n + FROM freight.wagons + WHERE current_yard_id = $1 AND deleted_at IS NULL + AND status = 'AVAILABLE' AND train_id IS NULL + AND current_train_schedule_id IS NULL + GROUP BY wagon_type_id`, + [yardId], + ); + return { + mode: 'YARD', + remainingByTypeId: new Map(rows.map((r: any) => [r.wagonTypeId, r.n])), + codesByTypeId: codes, + }; +} + +/** Per-type slot counts of a plan, as a readable "10x PW2, 17x NW5". */ +const planByType = (plan: any[]): string => { + const counts = new Map(); + for (const slot of plan) { + counts.set(slot.wagonTypeCode, (counts.get(slot.wagonTypeCode) ?? 0) + 1); + } + return [...counts.entries()].map(([code, n]) => `${n}x ${code}`).join(', ') || 'none'; +}; + +async function main() { + await ds.initialize(); + const { allowed, codes } = await loadConfig(); + const dctStock = await stockAtYard(YARD.DCT, codes); + + console.log('\n=== REAL FLEET AT DORALEH_FREEZONE (AVAILABLE, unpinned) ==='); + console.table( + [...dctStock.remainingByTypeId.entries()].map(([id, n]) => ({ + wagonType: codes.get(id), + available: n, + })), + ); + + // ---------------------------------------------------------------- CASE A + { + const checks: Check[] = []; + const bookings = await loadBookings(['WGT-A1', 'WGT-A2', 'WGT-A3']); + // Constrain the fleet to make the contest real: 20 NW5 + 10 PW2. + const stock: WagonStock = { + mode: 'TRAIN', + remainingByTypeId: new Map([ + ['8f717b09-eec1-46ad-be3d-2dc0a56e55e7', 20], // NW5 + ['8eec3a7d-8482-4397-96b6-59a028210722', 10], // PW2 + ]), + codesByTypeId: codes, + }; + const result = planWagonsWithStock({ bookings, allowed, stock }); + const bulkSlots = result.plan.filter((s) => s.slotLoadType === 'BULK'); + const pw2Bulk = bulkSlots.filter((s) => s.wagonTypeCode === 'PW2').length; + const nw5Bulk = bulkSlots.filter((s) => s.wagonTypeCode === 'NW5').length; + + check(checks, 'bulk fills bulk-only PW2 first', pw2Bulk === 10, + `${pw2Bulk}/10 PW2 used by bulk`); + check(checks, 'PW2 bulk wagons respect the 20T cargo cap', + bulkSlots.filter((s) => s.wagonTypeCode === 'PW2') + .every((s) => s.assignedWeightTons <= 20), + `max PW2 load ${Math.max(0, ...bulkSlots.filter((s) => s.wagonTypeCode === 'PW2').map((s) => s.assignedWeightTons))}T (cap 20T)`); + check(checks, 'NW5 bulk wagons respect the 30T cargo cap', + bulkSlots.filter((s) => s.wagonTypeCode === 'NW5') + .every((s) => s.assignedWeightTons <= 30), + `max NW5 bulk load ${Math.max(0, ...bulkSlots.filter((s) => s.wagonTypeCode === 'NW5').map((s) => s.assignedWeightTons))}T (cap 30T)`); + check(checks, 'no wagon mixes bulk with other cargo', + validateWagonCargoExclusivity(result.plan).length === 0, + validateWagonCargoExclusivity(result.plan).join('; ') || 'clean'); + check(checks, 'plan is honest about who fits', + result.fitting.length + result.deferred.length === 3, + `fitting=[${result.fitting.map((b) => b.reference)}] deferred=[${result.deferred.map((d) => d.reference)}]`); + + results.push({ testCase: 'A — bulk vs container contest for NW5 (20 NW5 + 10 PW2)', checks }); + console.log('\n=== CASE A: 695T Perishable + 12x40ft + 16x20ft, stock 20 NW5 / 10 PW2 ==='); + console.log(`plan: ${result.plan.length} wagons — ${planByType(result.plan)}`); + console.log(` bulk: ${pw2Bulk}x PW2 + ${nw5Bulk}x NW5`); + console.log(`fitting: ${result.fitting.map((b) => b.reference).join(', ') || 'none'}`); + for (const d of result.deferred) console.log(`deferred: ${d.reference} — ${d.reason}`); + } + + // ---------------------------------------------------------------- CASE B + { + const checks: Check[] = []; + const bookings = await loadBookings(['WGT-B1', 'WGT-B2']); + const stops = [YARD.DCT, YARD.DIRE, YARD.KALITY]; + const legs = new Map( + bookings.map((b: any) => [ + b.id, + { from: stops.indexOf(b.originYardId), to: stops.indexOf(b.destinationYardId) }, + ]), + ); + const stock: WagonStock = { + mode: 'TRAIN', + remainingByTypeId: new Map([ + ['8f717b09-eec1-46ad-be3d-2dc0a56e55e7', 3], // NW5 — only 3, forces reuse + ['8eec3a7d-8482-4397-96b6-59a028210722', 0], + ]), + codesByTypeId: codes, + }; + const result = planWagonsWithStock({ + bookings, allowed, stock, legs, edgeCount: 2, stops, + }); + check(checks, 'both disjoint-leg bookings fit on 3 wagons', + result.fitting.length === 2 && result.plan.length <= 3, + `fitting=${result.fitting.length}/2, wagons=${result.plan.length}`); + const legIssues = validateWagonCargoExclusivity(result.plan, legs, 2); + check(checks, 'no wagon carries bulk + container on the SAME leg', + legIssues.length === 0, legIssues.join('; ') || 'clean'); + // …and the leg-blind reading WOULD flag it, proving the reuse is real. + check(checks, 'the same wagon does carry both kinds across DIFFERENT legs', + validateWagonCargoExclusivity(result.plan).length > 0, + 'leg-blind check sees bulk+container on one wagon (legal: disjoint legs)'); + const reused = result.plan.filter((s) => s.allocations.length > 1); + check(checks, 'leg-disjoint reuse actually happens', + reused.length > 0, + `${reused.length} wagon(s) carry both bookings on different legs`); + + results.push({ testCase: 'B — leg-disjoint reuse (container leg 1, bulk leg 2), only 3 NW5', checks }); + console.log('\n=== CASE B: 6x20ft DCT->Dire + 120T bulk Dire->Kality, only 3 NW5 ==='); + console.log(`plan: ${result.plan.length} wagons — ${planByType(result.plan)}`); + for (const slot of result.plan) { + console.log(` wagon #${slot.sequenceNo} (${slot.wagonTypeCode}): ${slot.allocations + .map((a) => `${a.bookingReference}/${a.loadType} ${a.allocatedWeightTons}T`).join(' + ')}`); + } + for (const d of result.deferred) console.log(`deferred: ${d.reference} — ${d.reason}`); + } + + // ---------------------------------------------------------------- CASE C + { + const checks: Check[] = []; + const bookings = await loadBookings(['WGT-C1']); + const stock: WagonStock = { + mode: 'TRAIN', + remainingByTypeId: new Map([ + ['8f717b09-eec1-46ad-be3d-2dc0a56e55e7', 20], // NW5 (40T cap for steel) + ['8eec3a7d-8482-4397-96b6-59a028210722', 20], // PW2 (10T cap for steel!) + ]), + codesByTypeId: codes, + }; + const result = planWagonsWithStock({ bookings, allowed, stock }); + const nw5 = result.plan.filter((s) => s.wagonTypeCode === 'NW5').length; + const pw2 = result.plan.filter((s) => s.wagonTypeCode === 'PW2').length; + check(checks, 'inverted caps: uses the 40T NW5, not the 10T PW2', + nw5 === 10 && pw2 === 0, + `${nw5}x NW5 (40T cap) + ${pw2}x PW2 (10T cap) for 400T`); + check(checks, 'wagon count matches the cap math (400/40 = 10)', + result.plan.length === 10, `${result.plan.length} wagons`); + results.push({ testCase: 'C — cap inversion (Steel Billet: NW5 40T vs PW2 10T)', checks }); + console.log('\n=== CASE C: 400T Steel Billet, caps NW5=40T PW2=10T, both in stock ==='); + console.log(`plan: ${result.plan.length} wagons — ${planByType(result.plan)}`); + } + + // ---------------------------------------------------------------- CASE D + { + const checks: Check[] = []; + const bookings = await loadBookings(['WGT-D1', 'WGT-D2']); + const stock = await stockAtYard(YARD.DCT, codes); + const result = planWagonsWithStock({ bookings, allowed, stock }); + const d1 = result.plan.filter((s) => + s.allocations.some((a) => a.bookingReference === 'WGT-D1')); + const d2 = result.plan.filter((s) => + s.allocations.some((a) => a.bookingReference === 'WGT-D2')); + check(checks, 'Beans (PW2-only) is deferred — no PW2 free at DCT', + result.deferred.some((d) => d.reference === 'WGT-D1'), + result.deferred.find((d) => d.reference === 'WGT-D1')?.reason ?? `planned on ${planByType(d1)}`); + check(checks, 'Sand rides only its configured CW3/CW4', + d2.length > 0 && d2.every((s) => ['CW3', 'CW4'].includes(s.wagonTypeCode)), + `Sand on ${planByType(d2)}`); + results.push({ testCase: 'D — exclusive-type cargo against REAL DCT stock', checks }); + console.log('\n=== CASE D: 200T Beans (PW2-only) + 300T Sand (CW3/CW4-only), REAL stock ==='); + console.log(`plan: ${result.plan.length} wagons — ${planByType(result.plan)}`); + for (const d of result.deferred) console.log(`deferred: ${d.reference} — ${d.reason}`); + } + + // ---------------------------------------------------------------- CASE E + { + const checks: Check[] = []; + const bookings = await loadBookings(['WGT-E1', 'WGT-E2']); + const stock: WagonStock = { + mode: 'TRAIN', + remainingByTypeId: new Map([ + ['8f717b09-eec1-46ad-be3d-2dc0a56e55e7', 30], + ['8eec3a7d-8482-4397-96b6-59a028210722', 5], + ]), + codesByTypeId: codes, + }; + const result = planWagonsWithStock({ bookings, allowed, stock }); + check(checks, '3000T booking is deferred, not silently truncated', + result.deferred.some((d) => d.reference === 'WGT-E1'), + result.deferred.find((d) => d.reference === 'WGT-E1')?.reason ?? 'FITTED (unexpected)'); + const e2 = result.plan.filter((s) => + s.allocations.some((a) => a.bookingReference === 'WGT-E2')); + check(checks, 'the 45T booking never shares a wagon with another booking', + e2.every((s) => new Set(s.allocations.map((a) => a.bookingId)).size === 1), + `${e2.length} wagon(s), all single-booking`); + check(checks, 'exclusivity holds across the whole plan', + validateWagonCargoExclusivity(result.plan).length === 0, + validateWagonCargoExclusivity(result.plan).join('; ') || 'clean'); + results.push({ testCase: 'E — overload one leg (3000T) + small bulk beside it', checks }); + console.log('\n=== CASE E: 3000T + 45T Perishable, stock 30 NW5 / 5 PW2 ==='); + console.log(`plan: ${result.plan.length} wagons — ${planByType(result.plan)}`); + console.log(`fitting: ${result.fitting.map((b) => b.reference).join(', ') || 'none'}`); + for (const d of result.deferred) console.log(`deferred: ${d.reference} — ${d.reason}`); + } + + // ---------------------------------------------------------------- REPORT + console.log('\n\n================ TEST REPORT ================'); + let passed = 0; + let failed = 0; + for (const group of results) { + console.log(`\n${group.testCase}`); + for (const c of group.checks) { + console.log(` ${c.pass ? 'PASS' : 'FAIL'} ${c.name}\n ${c.detail}`); + c.pass ? (passed += 1) : (failed += 1); + } + } + console.log(`\n---------------------------------------------`); + console.log(`TOTAL: ${passed} passed, ${failed} failed`); + await ds.destroy(); + process.exit(failed > 0 ? 1 : 0); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/apps/edr-freight-api/scripts/seed-s45-scenario.cjs b/apps/edr-freight-api/scripts/seed-s45-scenario.cjs new file mode 100644 index 000000000..39c3f1125 --- /dev/null +++ b/apps/edr-freight-api/scripts/seed-s45-scenario.cjs @@ -0,0 +1,96 @@ +/** + * Seeds the S-2026-00045 reported scenario into edr_dev (idempotent). + * References SF45-* ("Scenario Fortyfive"). Rows are kept as evidence. + * + * SELECT * FROM freight.bookings WHERE reference LIKE 'SF45-%'; + */ +const { Client } = require('pg'); + +const YARD = { + DCT: 'fc558b95-da28-4fc3-8348-311a290c34ae', + DIRE: 'f7b1686f-d43e-42aa-bc6a-d3d5849296c9', + GMP: '61ae1e66-c229-4dcd-9851-b2b9424f3a95', // KALITY = GMP (Gelan) +}; +const PERISHABLE = 'a5991d3a-d690-4b7e-98fd-ea3333aa16e7'; // NW5 30T / PW2 20T +const FT40 = '349072e7-8a90-4c03-b682-08976abfd7e8'; +const COMPANY = '300d5510-e3a5-4858-bc28-6e3beda8ca80'; + +const BOOKINGS = [ + { ref: 'SF45-1', type: 'CONTAINER', from: 'DCT', to: 'DIRE', qty: 9, + note: 'S45: DCT->Dire 9x40ft' }, + { ref: 'SF45-2', type: 'CONTAINER', from: 'DCT', to: 'DIRE', qty: 19, + note: 'S45: DCT->Dire 19x40ft' }, + { ref: 'SF45-3', type: 'CONTAINER', from: 'DCT', to: 'DIRE', qty: 19, + note: 'S45: DCT->Dire 19x40ft (second)' }, + { ref: 'SF45-4', type: 'BULK', from: 'DCT', to: 'GMP', tons: 695, + note: 'S45: DCT->GMP 695T Perishable - THE ONE NOT BEING SELECTED' }, + { ref: 'SF45-5', type: 'CONTAINER', from: 'DIRE', to: 'GMP', qty: 42, + note: 'S45: Dire->GMP 42x40ft' }, +]; + +async function main() { + const client = new Client({ + host: process.env.DB_HOST || '10.18.7.207', + port: Number(process.env.DB_PORT || 5432), + database: process.env.DB_NAME || 'edr_dev', + user: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || 'dcba@1234', + }); + await client.connect(); + const out = []; + + for (const b of BOOKINGS) { + const isBulk = b.type === 'BULK'; + const vgmPerUnit = 26; + const totalVgm = isBulk ? b.tons : b.qty * vgmPerUnit; + + const existing = await client.query( + `SELECT id FROM freight.bookings WHERE reference = $1`, [b.ref]); + let id; + if (existing.rows.length) { + id = existing.rows[0].id; + await client.query( + `UPDATE freight.bookings + SET cargo_total_weight_vgm=$2, cargo_type_id=$3, origin_yard_id=$4, + destination_yard_id=$5, freight_type=$6, trade_direction='IMPORT', + status='CLEARANCE_READY', payment_status='PENDING', + scheduling_status='ELIGIBLE', train_schedule_id=NULL, + cargo_free_text=$7, updated_at=now() + WHERE id=$1`, + [id, totalVgm, isBulk ? PERISHABLE : null, YARD[b.from], YARD[b.to], + b.type, b.note]); + } else { + const res = await client.query( + `INSERT INTO freight.bookings ( + reference, status, total_amount, payment_status, contract_type, + trade_direction, equipment_return, cargo_total_weight_vgm, is_hazardous, + payment_currency, version_number, priority_score, origin_yard_id, + destination_yard_id, cargo_type_id, cargo_free_text, freight_type, + scheduling_status, is_government, customs_clearing_enabled, is_reefer, + booking_type, is_split, company_id, created_at, updated_at + ) VALUES ($1,'CLEARANCE_READY',0,'PENDING','NEW','IMPORT','WITHOUT_RETURN', + $2,false,'ETB',1,0,$3,$4,$5,$6,$7,'ELIGIBLE',false,false,false, + 'ONE_TIME',false,$8, now(), now()) + RETURNING id`, + [b.ref, totalVgm, YARD[b.from], YARD[b.to], + isBulk ? PERISHABLE : null, b.note, b.type, COMPANY]); + id = res.rows[0].id; + } + + await client.query(`DELETE FROM freight.booking_container WHERE booking_id=$1`, [id]); + if (!isBulk) { + await client.query( + `INSERT INTO freight.booking_container + (booking_id, container_type_id, quantity, vgm_per_unit_tons, + total_vgm_tons, wagons_required, container_size, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,'40ft', now(), now())`, + [id, FT40, b.qty, vgmPerUnit, b.qty * vgmPerUnit, b.qty]); + } + out.push({ ref: b.ref, id, note: b.note }); + } + + console.table(out); + await client.end(); +} + +main().catch((e) => { console.error(e.message); process.exit(1); }); diff --git a/apps/edr-freight-api/scripts/seed-wagon-gate-testcases.cjs b/apps/edr-freight-api/scripts/seed-wagon-gate-testcases.cjs new file mode 100644 index 000000000..e37dc6536 --- /dev/null +++ b/apps/edr-freight-api/scripts/seed-wagon-gate-testcases.cjs @@ -0,0 +1,142 @@ +/** + * Seeds the wagon-allocation test bookings into edr_dev (idempotent) and + * prints their ids. Rows are kept — they are the evidence for the report. + * + * References are prefixed WGT- (Wagon Gate Test) so they are easy to find: + * SELECT * FROM freight.bookings WHERE reference LIKE 'WGT-%'; + */ +const { Client } = require('pg'); + +const YARD = { + DORALEH_FREEZONE: 'fc558b95-da28-4fc3-8348-311a290c34ae', + DIRE_DAWA: 'f7b1686f-d43e-42aa-bc6a-d3d5849296c9', + KALITY: '61ae1e66-c229-4dcd-9851-b2b9424f3a95', + DMP: '7b658678-ce2b-41aa-8dc5-b38ba4a76e9b', +}; +const CARGO = { + PERISHABLE: 'a5991d3a-d690-4b7e-98fd-ea3333aa16e7', // NW5 30T / PW2 20T + STEEL_BILLET: '8291ccc3-0dd9-4d78-aa44-284d028a19ce', // NW5 40T / PW2 10T (inverted) + BEANS: '9afd8eb3-975b-4ea7-a7db-04eb2c20a04d', // PW2 only, no cap + SAND: '8942884d-9991-42bb-87f7-a70930f8c43c', // CW3/CW4 only, no cap +}; +const CONTAINER = { + '20FT': '77cf24ec-e74b-4bdb-b1cc-6f336379cc58', + '40FT': '349072e7-8a90-4c03-b682-08976abfd7e8', +}; +const COMPANY = '300d5510-e3a5-4858-bc28-6e3beda8ca80'; + +/** Test bookings: 4 corridors, bulk + container, contested and uncontested. */ +const BOOKINGS = [ + // --- Case A: DORALEH_FREEZONE -> KALITY (full leg), bulk vs container contest + { ref: 'WGT-A1', dir: 'IMPORT', type: 'BULK', from: 'DORALEH_FREEZONE', to: 'KALITY', + cargo: 'PERISHABLE', tons: 695, note: 'A: 695T Perishable, contends with containers for NW5' }, + { ref: 'WGT-A2', dir: 'IMPORT', type: 'CONTAINER', from: 'DORALEH_FREEZONE', to: 'KALITY', + containers: [{ type: '40FT', qty: 12, vgm: 26 }], note: 'A: 12x40ft, needs 12 container wagons' }, + { ref: 'WGT-A3', dir: 'IMPORT', type: 'CONTAINER', from: 'DORALEH_FREEZONE', to: 'KALITY', + containers: [{ type: '20FT', qty: 16, vgm: 12 }], note: 'A: 16x20ft = 8 wagons (TEU paired)' }, + + // --- Case B: sub-corridor legs on the same 3-stop route (leg overlap) + { ref: 'WGT-B1', dir: 'IMPORT', type: 'CONTAINER', from: 'DORALEH_FREEZONE', to: 'DIRE_DAWA', + containers: [{ type: '20FT', qty: 6, vgm: 14 }], note: 'B: leg 1 only (DCT->Dire), 3 wagons' }, + { ref: 'WGT-B2', dir: 'IMPORT', type: 'BULK', from: 'DIRE_DAWA', to: 'KALITY', + cargo: 'PERISHABLE', tons: 120, note: 'B: leg 2 only (Dire->Kality) - may reuse leg-1 wagons' }, + + // --- Case C: cap inversion - Steel Billet is CHEAPER on NW5 (40T) than PW2 (10T) + { ref: 'WGT-C1', dir: 'IMPORT', type: 'BULK', from: 'DORALEH_FREEZONE', to: 'KALITY', + cargo: 'STEEL_BILLET', tons: 400, note: 'C: inverted caps - must NOT blindly take PW2' }, + + // --- Case D: exclusive-type cargo (Beans=PW2 only, Sand=CW3/CW4 only) + { ref: 'WGT-D1', dir: 'IMPORT', type: 'BULK', from: 'DORALEH_FREEZONE', to: 'KALITY', + cargo: 'BEANS', tons: 200, note: 'D: PW2-only cargo, no alternative' }, + { ref: 'WGT-D2', dir: 'IMPORT', type: 'BULK', from: 'DORALEH_FREEZONE', to: 'KALITY', + cargo: 'SAND', tons: 300, note: 'D: CW3/CW4-only cargo' }, + + // --- Case E: overload one leg (way beyond any train) + { ref: 'WGT-E1', dir: 'IMPORT', type: 'BULK', from: 'DORALEH_FREEZONE', to: 'KALITY', + cargo: 'PERISHABLE', tons: 3000, note: 'E: 3000T - must overflow the loco pull limit' }, + { ref: 'WGT-E2', dir: 'IMPORT', type: 'BULK', from: 'DORALEH_FREEZONE', to: 'KALITY', + cargo: 'PERISHABLE', tons: 45, note: 'E: small bulk - must NOT share a wagon with A1' }, +]; + +async function main() { + const client = new Client({ + host: process.env.DB_HOST || '10.18.7.207', + port: Number(process.env.DB_PORT || 5432), + database: process.env.DB_NAME || 'edr_dev', + user: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || 'dcba@1234', + }); + await client.connect(); + + const out = []; + for (const b of BOOKINGS) { + const isBulk = b.type === 'BULK'; + const totalVgm = isBulk + ? b.tons + : b.containers.reduce((s, l) => s + l.qty * l.vgm, 0); + + const existing = await client.query( + `SELECT id FROM freight.bookings WHERE reference = $1`, + [b.ref], + ); + let id; + if (existing.rows.length) { + id = existing.rows[0].id; + await client.query( + `UPDATE freight.bookings + SET cargo_total_weight_vgm = $2, cargo_type_id = $3, + origin_yard_id = $4, destination_yard_id = $5, + freight_type = $6, trade_direction = $7, + status = 'CLEARANCE_READY', payment_status = 'PENDING', + scheduling_status = 'ELIGIBLE', train_schedule_id = NULL, + cargo_free_text = $8, updated_at = now() + WHERE id = $1`, + [id, totalVgm, isBulk ? CARGO[b.cargo] : null, + YARD[b.from], YARD[b.to], b.type, b.dir, b.note], + ); + } else { + const res = await client.query( + `INSERT INTO freight.bookings ( + reference, status, total_amount, payment_status, contract_type, + trade_direction, equipment_return, cargo_total_weight_vgm, + is_hazardous, payment_currency, version_number, priority_score, + origin_yard_id, destination_yard_id, cargo_type_id, cargo_free_text, + freight_type, scheduling_status, is_government, + customs_clearing_enabled, is_reefer, booking_type, is_split, + company_id, created_at, updated_at + ) VALUES ($1,'CLEARANCE_READY',0,'PENDING','NEW',$2,'WITHOUT_RETURN',$3, + false,'ETB',1,0,$4,$5,$6,$7,$8,'ELIGIBLE',false, + false,false,'ONE_TIME',false,$9, now(), now()) + RETURNING id`, + [b.ref, b.dir, totalVgm, YARD[b.from], YARD[b.to], + isBulk ? CARGO[b.cargo] : null, b.note, b.type, COMPANY], + ); + id = res.rows[0].id; + } + + // Container lines + await client.query(`DELETE FROM freight.booking_container WHERE booking_id = $1`, [id]); + if (!isBulk) { + for (const line of b.containers) { + await client.query( + `INSERT INTO freight.booking_container + (booking_id, container_type_id, quantity, vgm_per_unit_tons, + total_vgm_tons, wagons_required, container_size, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7, now(), now())`, + [id, CONTAINER[line.type], line.qty, line.vgm, line.qty * line.vgm, + line.type === '40FT' ? line.qty : Math.ceil(line.qty / 2), + line.type === '40FT' ? '40ft' : '20ft'], + ); + } + } + out.push({ ref: b.ref, id, note: b.note }); + } + + console.table(out); + await client.end(); +} + +main().catch((err) => { + console.error(err.message); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index f9eab4d39..68bbdcba4 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -75,7 +75,7 @@ export const TrainSchedulingView = () => BookingStaff(FREIGHT_PERMS.trainScheduling.view); // Granular train-scheduling actions replace the retired coarse manage: -// create a schedule, update (assign/consist/loading/finalize/dispatch/arrive…), +// create a schedule, update (assign/consist/finalize/dispatch/arrive…), // cancel a schedule, reschedule (+ maintenance), and manage global rules. export const TrainSchedulingCreate = () => BookingStaff(FREIGHT_PERMS.trainScheduling.create); @@ -83,6 +83,18 @@ export const TrainSchedulingCreate = () => export const TrainSchedulingUpdate = () => BookingStaff(FREIGHT_PERMS.trainScheduling.update); +/** + * Confirm a booking's cargo loaded/unloaded at a yard — carved out of the + * coarse `update` so it can be granted independently of general schedule + * editing. Same two keys gate import, export, and intercity movements alike: + * the generic per-booking route and the intercity-specific one both use them. + */ +export const TrainSchedulingLoad = () => + BookingStaff(FREIGHT_PERMS.trainScheduling.load); + +export const TrainSchedulingUnload = () => + BookingStaff(FREIGHT_PERMS.trainScheduling.unload); + export const TrainSchedulingCancel = () => BookingStaff(FREIGHT_PERMS.trainScheduling.cancel); diff --git a/apps/edr-freight-api/src/config/eims.config.spec.ts b/apps/edr-freight-api/src/config/eims.config.spec.ts index 127b3ea62..a6aa3895d 100644 --- a/apps/edr-freight-api/src/config/eims.config.spec.ts +++ b/apps/edr-freight-api/src/config/eims.config.spec.ts @@ -70,62 +70,3 @@ describe("eims.config — private key / certificate resolution", () => { ); }); }); - -describe("eims.config — baked-in Ethiopia region/zone/woreda codes", () => { - it("resolves a known region/wereda/zone with no env var set at all", () => { - withEnv( - { ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" }, - () => { - const cfg = eimsConfigFactory(); - expect(cfg.invoice.buyerRegionCodes.Somali).toBe("05"); - expect(cfg.invoice.buyerWeredaCodes["Jijiga Town"]).toBe("02"); - expect(cfg.invoice.buyerCityCodes.Fafan).toBe("01"); - }, - ); - }); - - it("an env var entry overrides the baked-in code for the same name", () => { - withEnv( - { - ...REQUIRED, - EIMS_PRIVATE_KEY: "x", - EIMS_CERTIFICATE_PATH: "/dev/null", - EIMS_BUYER_REGION_CODES: "Somali=99", - }, - () => { - expect(eimsConfigFactory().invoice.buyerRegionCodes.Somali).toBe("99"); - }, - ); - }); - - it("an env var still adds a name the baked-in table doesn't have (a spelling variant)", () => { - withEnv( - { - ...REQUIRED, - EIMS_PRIVATE_KEY: "x", - EIMS_CERTIFICATE_PATH: "/dev/null", - EIMS_BUYER_CITY_CODES: "Fafen=01", - }, - () => { - const codes = eimsConfigFactory().invoice.buyerCityCodes; - expect(codes.Fafen).toBe("01"); - expect(codes.Fafan).toBe("01"); // baked-in entry still present alongside it - }, - ); - }); - - it("resolves the bare Addis Ababa sub-city name a buyer profile actually stores, not the CSV's example-woreda name", () => { - withEnv( - { ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" }, - () => { - const codes = eimsConfigFactory().invoice.buyerWeredaCodes; - expect(codes.Bole).toBe("01"); - expect(codes.Arada).toBe("01"); - expect(codes.Kirkos).toBe("01"); - expect(codes.Yeka).toBe("01"); - expect(codes["Nifas Silk Lafto"]).toBe("13"); - expect(codes["Nefas Silk-Lafto"]).toBe("13"); - }, - ); - }); -}); diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index e5530eaf2..e0140ba68 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -1,6 +1,5 @@ import { registerAs } from "@nestjs/config"; -import { ETHIOPIA_REGION_CODES, ETHIOPIA_WOREDA_CODES, ETHIOPIA_ZONE_CODES } from "./ethiopia-geo-codes"; /** * Ethiopian MoR EIMS e-invoicing gateway. @@ -99,35 +98,6 @@ export interface EimsInvoiceConfig { paymentMode: string; paymentTerm: string; unitDefault: string; - /** - * Domestic fallback only — used when the buyer's `Company.country` is empty or "Ethiopia" (the - * column's own default) and not already listed in `buyerCountryCodes`. A genuinely foreign - * buyer must be in `buyerCountryCodes` by name or the mapping fails locally; this value is never - * applied to them, so an unconfigured foreign country can't silently be filed as Ethiopia. - */ - buyerCountryCode: string | null; - /** - * Country name → MoR code, from `EIMS_BUYER_COUNTRY_CODES` ("Ethiopia=231,Djibouti=071"). Format - * unconfirmed (unlike Region/Wereda, MoR has never named a Country regex), so — unlike them — - * this is not validated against a fixed digit pattern, only looked up by name. - */ - buyerCountryCodes: Record; - /** - * Buyer region name → MoR numeric code, from `EIMS_BUYER_REGION_CODES` - * ("Addis Ababa=13,Oromia=4"). A buyer whose region is neither a code nor in this map fails - * locally rather than being filed with a guessed one. - */ - buyerRegionCodes: Record; - /** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */ - buyerWeredaCodes: Record; - /** - * Buyer *zone* name → MoR City code, from `EIMS_BUYER_CITY_CODES` ("Kirkos=101"). `Company` has - * no dedicated city column — Zone is the closest match in EDR's own data. Optional, unlike - * Region/Wereda: MoR has never required City on a live buyer (confirmed — filing already - * succeeds with it null), so an unmapped zone falls back to null rather than failing the - * mapping. - */ - buyerCityCodes: Record; /** * Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` + * `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to @@ -258,13 +228,6 @@ export default registerAs("eims", (): EimsConfig => { paymentMode: process.env.EIMS_PAYMENT_MODE ?? "", paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "", unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "", - buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null, - buyerCountryCodes: parseCodeMap(process.env.EIMS_BUYER_COUNTRY_CODES), - // Baked-in Ethiopia reference table first, env var entries win on a name collision — lets a - // deployment override or add to it without a redeploy. See ethiopia-geo-codes.ts. - buyerRegionCodes: { ...ETHIOPIA_REGION_CODES, ...parseCodeMap(process.env.EIMS_BUYER_REGION_CODES) }, - buyerWeredaCodes: { ...ETHIOPIA_WOREDA_CODES, ...parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES) }, - buyerCityCodes: { ...ETHIOPIA_ZONE_CODES, ...parseCodeMap(process.env.EIMS_BUYER_CITY_CODES) }, taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE), taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE), exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE), diff --git a/apps/edr-freight-api/src/config/ethiopia-geo-codes.ts b/apps/edr-freight-api/src/config/ethiopia-geo-codes.ts deleted file mode 100644 index ca48a7c5f..000000000 --- a/apps/edr-freight-api/src/config/ethiopia-geo-codes.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * MoR EIMS region/zone/woreda codes, by name — the baked-in fallback under - * `EIMS_BUYER_REGION_CODES`/`EIMS_BUYER_WEREDA_CODES`/`EIMS_BUYER_CITY_CODES` (zone is the closest - * match to EIMS's "City", per `eims-invoice.mapper.ts`). - * - * Before this existed, every buyer from a not-yet-seen region/zone/woreda crashed EIMS filing until - * someone hunted down the code and added it to an env var by hand — happened three times in one - * afternoon (2026-08-17: Somali region, Fafan zone, Jigjiga woreda, even the Ethiopia country code - * itself were all unset). Ethiopia's administrative divisions are fixed, known, reference data, not - * something that should be maintained reactively per buyer. Source: `ethiopia_administrative_ - * hierarchy_master.csv`, supplied 2026-08-17 — NOT exhaustive (a representative sample per region, - * not all ~1000 real woredas), extend as new gaps surface. - * - * The env vars stay wired in ahead of this table (see `eims.config.ts`) — for a quick correction - * without a redeploy, or a name spelled differently in a buyer's profile than in this table (already - * hit live: DB has zone "Fafen", this table's official spelling is "Fafan" — same zone, matching is - * case/space-insensitive but not spelling-tolerant, so the env var override is still how that buyer - * actually resolves; this table mainly helps the *next* buyer whose profile spelling matches). - * - * ponytail: region names are unique nationwide (only ~15), safe as a flat map. Zone and woreda names - * are not always unique across different regions (e.g. "North Shewa" is both an Amhara zone and an - * Oromia zone, different codes) — `Company` stores region/zone/woreda as three independent strings, - * no parent linkage, so a flat name lookup can't disambiguate. First occurrence in the source data - * wins on a collision. Only affects the optional `City` field (zone) — never blocks filing, unlike - * Region/Wereda. A correct fix needs `Company` to store a linked hierarchy, not just three strings; - * out of scope here. Upgrade path: key this by `${region}/${zone}` once that linkage exists. - */ -const ROWS: Array<[region: string, zone: string, woreda: string, regionCode: string, zoneCode: string, woredaCode: string]> = [ - ["Tigray", "Western Tigray", "Humera", "01", "01", "01"], - ["Tigray", "Western Tigray", "Kafta Humera", "01", "01", "02"], - ["Tigray", "Western Tigray", "Tsegede", "01", "01", "03"], - ["Tigray", "North Western Tigray", "Shire Endaselassie", "01", "02", "01"], - ["Tigray", "North Western Tigray", "Sheraro", "01", "02", "02"], - ["Tigray", "Central Tigray", "Axum", "01", "03", "01"], - ["Tigray", "Central Tigray", "Adwa", "01", "03", "02"], - ["Tigray", "Eastern Tigray", "Adigrat", "01", "04", "01"], - ["Tigray", "Southern Tigray", "Maychew", "01", "05", "01"], - ["Tigray", "Mekelle Special Zone", "Mekelle City", "01", "06", "01"], - ["Afar", "Awusi Rasu (Zone 1)", "Asayita", "02", "01", "01"], - ["Afar", "Awusi Rasu (Zone 1)", "Semera-Logiya", "02", "01", "02"], - ["Afar", "Kilbet Rasu (Zone 2)", "Abala", "02", "02", "01"], - ["Afar", "Gabi Rasu (Zone 3)", "Awash Fentale", "02", "03", "01"], - ["Afar", "Fantena Rasu (Zone 4)", "Yalo", "02", "04", "01"], - ["Afar", "Hari Rasu (Zone 5)", "Telalak", "02", "05", "01"], - ["Amhara", "North Gondar", "Debark", "03", "01", "01"], - ["Amhara", "South Gondar", "Debre Tabor", "03", "02", "01"], - ["Amhara", "North Wollo", "Woldiya", "03", "03", "01"], - ["Amhara", "South Wollo", "Dessie Town", "03", "04", "01"], - ["Amhara", "North Shewa", "Debre Berhan", "03", "05", "01"], - ["Amhara", "East Gojjam", "Debre Markos", "03", "06", "01"], - ["Amhara", "West Gojjam", "Finote Selam", "03", "07", "01"], - ["Amhara", "Wag Hemra", "Sekota", "03", "08", "01"], - ["Amhara", "Awi", "Injibara", "03", "09", "01"], - ["Amhara", "Oromia Special Zone", "Kemise", "03", "10", "01"], - ["Amhara", "Bahir Dar Special Zone", "Bahir Dar City", "03", "11", "01"], - ["Amhara", "Gondar Special Zone", "Gondar City", "03", "12", "01"], - ["Oromia", "North Shewa", "Fiche", "04", "01", "01"], - ["Oromia", "South West Shewa", "Waliso", "04", "02", "01"], - ["Oromia", "East Shewa", "Adama Town", "04", "03", "01"], - ["Oromia", "East Shewa", "Bishoftu Town", "04", "03", "02"], - ["Oromia", "West Shewa", "Ambo", "04", "04", "01"], - ["Oromia", "Arsi", "Asella", "04", "05", "01"], - ["Oromia", "West Arsi", "Shashemene", "04", "06", "01"], - ["Oromia", "Bale", "Robe", "04", "07", "01"], - ["Oromia", "East Hararghe", "Harar Outskirts", "04", "08", "01"], - ["Oromia", "West Hararghe", "Chiro", "04", "09", "01"], - ["Oromia", "Jimma", "Jimma Town", "04", "10", "01"], - ["Oromia", "Illubabor", "Mettu", "04", "11", "01"], - ["Oromia", "Buno Bedele", "Bedele", "04", "12", "01"], - ["Oromia", "Welega (West)", "Gimbi", "04", "13", "01"], - ["Oromia", "Welega (East)", "Nekemte", "04", "14", "01"], - ["Oromia", "Horo Guduru Welega", "Shambu", "04", "15", "01"], - ["Oromia", "Kelam Welega", "Dembidolo", "04", "16", "01"], - ["Oromia", "Borena", "Yabelo", "04", "17", "01"], - ["Oromia", "Guji", "Negele Borana", "04", "18", "01"], - ["Oromia", "West Guji", "Bule Hora", "04", "19", "01"], - ["Oromia", "East Bale", "Ginir", "04", "20", "01"], - ["Oromia", "Sheger City", "Sululta", "04", "21", "01"], - ["Somali", "Fafan", "Jijiga Woreda", "05", "01", "01"], - ["Somali", "Fafan", "Jijiga Town", "05", "01", "02"], - ["Somali", "Fafan", "Awbare", "05", "01", "03"], - ["Somali", "Sitti", "Shinile", "05", "02", "01"], - ["Somali", "Erer", "Fiq", "05", "03", "01"], - ["Somali", "Jarar", "Degehabur", "05", "04", "01"], - ["Somali", "Nogob", "Segeg", "05", "05", "01"], - ["Somali", "Korahe", "Kebridehar", "05", "06", "01"], - ["Somali", "Shabelle", "Gode", "05", "07", "01"], - ["Somali", "Afder", "Afder Woreda", "05", "08", "01"], - ["Somali", "Liben", "Filtu", "05", "09", "01"], - ["Somali", "Dhawa", "Mubarak", "05", "10", "01"], - ["Somali", "Dollo", "Warder", "05", "11", "01"], - ["Benishangul-Gumuz", "Asosa", "Asosa Woreda", "06", "01", "01"], - ["Benishangul-Gumuz", "Kamasashi", "Kamasashi Woreda", "06", "02", "01"], - ["Benishangul-Gumuz", "Metekel", "Gilgel Beles", "06", "03", "01"], - ["Southern Ethiopia", "Wolayta", "Sodo Zuria", "07", "01", "01"], - ["Southern Ethiopia", "Wolayta", "Sodo Town", "07", "01", "02"], - ["Southern Ethiopia", "Gamo", "Arba Minch Town", "07", "02", "01"], - ["Southern Ethiopia", "Gofa", "Sawla", "07", "03", "01"], - ["Southern Ethiopia", "Konso", "Konso Woreda", "07", "04", "01"], - ["Southern Ethiopia", "South Omo", "Jinka", "07", "05", "01"], - ["Gambela", "Anywaa", "Gambela Zuria", "08", "01", "01"], - ["Gambela", "Nuer", "Lare", "08", "02", "01"], - ["Gambela", "Majang", "Metu Zuria part", "08", "03", "01"], - ["Harari", "Harar Hundanee", "Amir Nur Woreda", "09", "01", "01"], - ["Harari", "Harar Hundanee", "Abadir Woreda", "09", "01", "02"], - ["Addis Ababa", "Bole Sub-City", "Bole Woreda 01", "10", "01", "01"], - ["Addis Ababa", "Kirkos Sub-City", "Kirkos Woreda 01", "10", "02", "01"], - ["Addis Ababa", "Nifas Silk Lafto", "NSL Woreda 13", "10", "03", "13"], - ["Addis Ababa", "Yeka Sub-City", "Yeka Woreda 01", "10", "04", "01"], - ["Addis Ababa", "Arada Sub-City", "Arada Woreda 01", "10", "05", "01"], - ["Dire Dawa", "Dire Dawa Urban", "Melka Jebdu", "11", "01", "01"], - ["Dire Dawa", "Dire Dawa Rural", "Gurgura", "11", "02", "01"], - ["Sidama", "Hawassa City Admin", "Hayek Chereka", "12", "01", "01"], - ["Sidama", "Sidama Zuria", "Yirgalem Town", "12", "02", "01"], - ["Sidama", "Sidama Zuria", "Aleta Wendo", "12", "02", "02"], - ["Southwest Ethiopia", "Keffa", "Bonga Town", "13", "01", "01"], - ["Southwest Ethiopia", "Sheka", "Mappi Zuria", "13", "02", "01"], - ["Southwest Ethiopia", "Bench Sheko", "Mizan Aman", "13", "03", "01"], - ["Central Ethiopia", "Gurage", "Wolkite", "14", "01", "01"], - ["Central Ethiopia", "Hadiya", "Hosaina", "14", "02", "01"], - ["Central Ethiopia", "Silte", "Worabe", "14", "03", "01"], - ["Gedeo State", "Gedeo Zone", "Dilla Zuria", "15", "01", "01"], - ["Gedeo State", "Gedeo Zone", "Yirgacheffe", "15", "01", "02"], -]; - -/** First occurrence wins on a name collision — see the class comment. */ -const buildMap = (pick: (row: (typeof ROWS)[number]) => [string, string]): Record => { - const map: Record = {}; - for (const row of ROWS) { - const [name, code] = pick(row); - if (!(name in map)) map[name] = code; - } - return map; -}; - -export const ETHIOPIA_REGION_CODES: Record = buildMap((r) => [r[0], r[3]]); -/** Zone name → code. Fed into `buyerCityCodes` — EIMS's "City" is really the buyer's zone. */ -export const ETHIOPIA_ZONE_CODES: Record = buildMap((r) => [r[1], r[4]]); -export const ETHIOPIA_WOREDA_CODES: Record = buildMap((r) => [r[2], r[5]]); - -/** - * Buyer records commonly store just the bare Addis Ababa sub-city name ("Bole", "Arada") as their - * woreda, not the source CSV's specific example-woreda name ("Bole Woreda 01") — confirmed live - * 2026-08-17 across three different buyers before any of them actually got past this check. Since - * the CSV lists exactly one representative woreda per Addis sub-city, alias the bare name to that - * same code rather than wait on a fuller table. - */ -const ADDIS_SUBCITY_ALIASES: Array<[bareName: string, csvZoneName: string]> = [ - ["Bole", "Bole Sub-City"], - ["Kirkos", "Kirkos Sub-City"], - ["Nifas Silk Lafto", "Nifas Silk Lafto"], - // Matches EIMS_BUYER_WEREDA_CODES' own existing spelling in .env — same zone, different hyphenation. - ["Nefas Silk-Lafto", "Nifas Silk Lafto"], - ["Yeka", "Yeka Sub-City"], - ["Arada", "Arada Sub-City"], -]; -for (const [bareName, csvZoneName] of ADDIS_SUBCITY_ALIASES) { - const row = ROWS.find((r) => r[1] === csvZoneName); - if (row && !(bareName in ETHIOPIA_WOREDA_CODES)) ETHIOPIA_WOREDA_CODES[bareName] = row[5]; -} diff --git a/apps/edr-freight-api/src/config/mor-location.resolver.spec.ts b/apps/edr-freight-api/src/config/mor-location.resolver.spec.ts new file mode 100644 index 000000000..0fc2607e2 --- /dev/null +++ b/apps/edr-freight-api/src/config/mor-location.resolver.spec.ts @@ -0,0 +1,275 @@ +import { MorLocationTuple } from "./mor-locations.data"; +import { + MorGeoMappingError, + normalizeName, + resolveMorGeo, + tryResolveMorGeo, +} from "./mor-location.resolver"; + +/** + * Rows copied verbatim out of the Ministry sheet (`EIMS_COUNTRY_REGION_VW`), chosen for the traps + * the real data contains rather than for tidiness: + * + * - BABILE and KERSA each exist in two different zones with different LOCALITY_NOs — the reason a + * global name lookup is unsafe and the hierarchy is mandatory. + * - ILLUBABOR has BURE twice under the same zone with different LOCALITY_NOs (691 and 890, the + * second with the Ministry's own trailing space) — a genuine ambiguity that must never be + * silently resolved to the first row. + * - "Wal-Mera" and "Akaki woreda" carry the sheet's mixed casing and punctuation. + */ +const FIXTURE: MorLocationTuple[] = [ + [70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 190, "JIJIGA"], + [70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 194, "BABILE"], + [70, "Ethiopia", 6, "SOMALI", 30, "SITI ZONE", 197, "DENBEL"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 495, "BABILE"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 482, "KERSA"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 503, "KERSA"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 691, "BURE"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 890, "BURE "], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 976, "Wal-Mera"], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 909, "Akaki woreda"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1100, "WOREDA 1"], + [253, "Djibouti", 1, "DJIBOUTI", 1, "DJIBOUTI VILLE", 1, "BALBALA"], +]; + +const JIJIGA = { + country: "Ethiopia", + region: "SOMALI", + zone: "FAAFAN ZONE", + woreda: "JIJIGA", +}; + +describe("normalizeName", () => { + it("collapses whitespace, trims, and compares case-insensitively", () => { + expect(normalizeName(" FAAFAN ZONE ")).toBe("FAAFAN ZONE"); + expect(normalizeName("faafan zone")).toBe("FAAFAN ZONE"); + expect(normalizeName(" FAAFAN ZONE ")).toBe(normalizeName("faafan zone")); + }); + + it("normalizes harmless punctuation and hyphen/space differences", () => { + expect(normalizeName("Wal-Mera")).toBe("WAL MERA"); + expect(normalizeName("Wal Mera")).toBe("WAL MERA"); + expect(normalizeName("ZONE 1 (AYSSAITA)")).toBe("ZONE 1 AYSSAITA"); + expect(normalizeName("Ber'ano")).toBe("BERANO"); + expect(normalizeName("KEAHORE/HADAT/")).toBe("KEAHORE HADAT"); + }); + + it("keeps digits, which several MoR locality names depend on", () => { + expect(normalizeName(" woreda 10 ")).toBe("WOREDA 10"); + expect(normalizeName("WOREDA 1")).not.toBe(normalizeName("WOREDA 10")); + }); +}); + +describe("resolveMorGeo", () => { + it("resolves the exact MoR spelling to the Ministry's own codes", () => { + expect(resolveMorGeo(JIJIGA, FIXTURE)).toEqual({ + Country: "70", + Region: "6", + City: "31", + Wereda: "190", + }); + }); + + it("resolves the EDR/e-Trade spellings through the alias layer", () => { + expect( + resolveMorGeo( + { + country: "Ethiopia", + region: "Somali", + zone: "Fafen", + woreda: "Jigjiga", + }, + FIXTURE, + ), + ).toEqual({ Country: "70", Region: "6", City: "31", Wereda: "190" }); + }); + + it("is case-insensitive", () => { + expect( + resolveMorGeo( + { + country: "ethiopia", + region: "somali", + zone: "faafan zone", + woreda: "jijiga", + }, + FIXTURE, + ), + ).toEqual({ Country: "70", Region: "6", City: "31", Wereda: "190" }); + }); + + it("ignores leading, trailing and repeated whitespace on every level", () => { + expect( + resolveMorGeo( + { + country: " Ethiopia ", + region: " SOMALI ", + zone: " FAAFAN ZONE ", + woreda: "\tJIJIGA ", + }, + FIXTURE, + ), + ).toEqual({ Country: "70", Region: "6", City: "31", Wereda: "190" }); + }); + + it("treats a hyphen as a space, in either direction", () => { + const expected = { Country: "70", Region: "2", City: "86", Wereda: "976" }; + const base = { + country: "Ethiopia", + region: "Oromia", + zone: "Finfine Vic Spec", + }; + expect(resolveMorGeo({ ...base, woreda: "Wal-Mera" }, FIXTURE)).toEqual(expected); + expect(resolveMorGeo({ ...base, woreda: "wal mera" }, FIXTURE)).toEqual(expected); + }); + + it("matches a zone whose MoR label carries the ' ZONE' suffix EDR does not store", () => { + expect(resolveMorGeo({ ...JIJIGA, zone: "Faafan" }, FIXTURE).City).toBe("31"); + expect( + resolveMorGeo( + { + country: "Ethiopia", + region: "Somali", + zone: "Siti", + woreda: "Denbel", + }, + FIXTURE, + ), + ).toEqual({ Country: "70", Region: "6", City: "30", Wereda: "197" }); + }); + + describe("a locality name that exists in more than one zone", () => { + it("picks BABILE by its full hierarchy, never by name alone", () => { + expect(resolveMorGeo({ ...JIJIGA, woreda: "BABILE" }, FIXTURE).Wereda).toBe("194"); + expect( + resolveMorGeo( + { + country: "Ethiopia", + region: "OROMIA", + zone: "MISRAK HARARGE", + woreda: "BABILE", + }, + FIXTURE, + ).Wereda, + ).toBe("495"); + }); + + it("picks KERSA by its full hierarchy", () => { + const oromia = { country: "Ethiopia", region: "OROMIA" }; + expect( + resolveMorGeo({ ...oromia, zone: "MISRAK HARARGE", woreda: "KERSA" }, FIXTURE).Wereda, + ).toBe("482"); + expect( + resolveMorGeo({ ...oromia, zone: "JIMMA ZONE", woreda: "KERSA" }, FIXTURE).Wereda, + ).toBe("503"); + }); + + it("does not let a locality leak across regions", () => { + // DENBEL exists under SOMALI/SITI ZONE only — asking for it under OROMIA must fail, not + // fall back to the nationwide match the old flat maps would have found. + expect(() => + resolveMorGeo( + { + country: "Ethiopia", + region: "OROMIA", + zone: "MISRAK HARARGE", + woreda: "DENBEL", + }, + FIXTURE, + ), + ).toThrow(/no MoR LOCALITY_DESC match/); + }); + }); + + describe("failures happen locally, before anything is filed", () => { + const cases: Array<[string, Record, RegExp]> = [ + ["unknown country", { ...JIJIGA, country: "Wakanda" }, /no MoR COUNTRY_NAME match/], + ["unknown region", { ...JIJIGA, region: "Atlantis" }, /no MoR PARISH_NAME match/], + ["unknown zone", { ...JIJIGA, zone: "Nowhere Zone" }, /no MoR CITY_NAME match/], + ["unknown woreda", { ...JIJIGA, woreda: "Example" }, /no MoR LOCALITY_DESC match/], + ]; + + it.each(cases)("%s fails with an actionable validation error", (_label, input, pattern) => { + expect(() => resolveMorGeo(input, FIXTURE)).toThrow(MorGeoMappingError); + expect(() => resolveMorGeo(input, FIXTURE)).toThrow(pattern); + }); + + it("names the offending address in the message so the company record can be corrected", () => { + expect(() => resolveMorGeo({ ...JIJIGA, woreda: "Example" }, FIXTURE)).toThrow( + /country="Ethiopia", region="SOMALI", zone="FAAFAN ZONE", woreda="Example"/, + ); + }); + + it("refuses an ambiguous locality instead of taking the first row", () => { + const input = { + country: "Ethiopia", + region: "OROMIA", + zone: "ILLUBABOR", + woreda: "BURE", + }; + expect(() => resolveMorGeo(input, FIXTURE)).toThrow(MorGeoMappingError); + expect(() => resolveMorGeo(input, FIXTURE)).toThrow(/ambiguous/); + // Both colliding codes are named, and neither is silently selected. + expect(() => resolveMorGeo(input, FIXTURE)).toThrow(/691, 890/); + expect(tryResolveMorGeo(input, FIXTURE)).toBeNull(); + }); + + it("fails loudly when the MoR master has not been generated yet", () => { + expect(() => resolveMorGeo(JIJIGA, [])).toThrow(/MoR location master is empty/); + }); + }); + + it("reproduces MoR's numeric values unchanged, as strings", () => { + const codes = resolveMorGeo(JIJIGA, FIXTURE); + expect(codes).toEqual({ + Country: "70", + Region: "6", + City: "31", + Wereda: "190", + }); + for (const value of Object.values(codes)) { + expect(typeof value).toBe("string"); + expect(value).toMatch(/^[0-9]+$/); + } + // The source row is the only origin of every code — no renumbering, no derivation. + const [countryNo, , parishNo, , cityNo, , localityNo] = FIXTURE[0]; + expect(codes).toEqual({ + Country: String(countryNo), + Region: String(parishNo), + City: String(cityNo), + Wereda: String(localityNo), + }); + }); + + it("never emits an Open Admin Data ETxx identifier", () => { + for (const value of Object.values(resolveMorGeo(JIJIGA, FIXTURE))) { + expect(value).not.toMatch(/^ET/i); + } + }); + + it("treats a blank country as domestic, matching the column default", () => { + expect(resolveMorGeo({ ...JIJIGA, country: "" }, FIXTURE).Country).toBe("70"); + expect(resolveMorGeo({ ...JIJIGA, country: null }, FIXTURE).Country).toBe("70"); + }); + + it("resolves a named foreign country rather than defaulting it to Ethiopia", () => { + expect( + resolveMorGeo( + { + country: "Djibouti", + region: "DJIBOUTI", + zone: "DJIBOUTI VILLE", + woreda: "BALBALA", + }, + FIXTURE, + ), + ).toEqual({ Country: "253", Region: "1", City: "1", Wereda: "1" }); + }); + + it("accepts a company record that already holds a MoR code, but only a real one", () => { + expect(resolveMorGeo({ ...JIJIGA, region: "6" }, FIXTURE).Region).toBe("6"); + expect(() => resolveMorGeo({ ...JIJIGA, region: "999" }, FIXTURE)).toThrow( + /no MoR PARISH_NAME match/, + ); + }); +}); diff --git a/apps/edr-freight-api/src/config/mor-location.resolver.ts b/apps/edr-freight-api/src/config/mor-location.resolver.ts new file mode 100644 index 000000000..72459db9b --- /dev/null +++ b/apps/edr-freight-api/src/config/mor-location.resolver.ts @@ -0,0 +1,255 @@ +import { BadRequestException } from "@nestjs/common"; + +import { MOR_LOCATIONS, MorLocationTuple } from "./mor-locations.data"; + +/** + * Resolves an EDR company address to the Ministry of Revenues' own EIMS location codes, using the + * MoR location master (`EIMS_COUNTRY_REGION_VW`) shipped in `mor-locations.data.ts`. + * + * MoR's field names do not line up with either EDR's or generic Ethiopian administrative datasets, + * so the mapping is fixed by the Ministry sheet, not by interpretation: + * + * Company.country -> COUNTRY_NAME -> COUNTRY_NO -> BuyerDetails.Country + * Company.region -> PARISH_NAME -> PARISH_NO -> BuyerDetails.Region + * Company.zone -> CITY_NAME -> CITY_NO -> BuyerDetails.City + * Company.woreda -> LOCALITY_DESC -> LOCALITY_NO -> BuyerDetails.Wereda + * + * This replaces the previous `EIMS_BUYER_*_CODES` environment maps and the `ethiopia-geo-codes.ts` + * table they layered over. Both invented their codes (sequential "01".."15" per region, from a + * generic administrative CSV) and both looked names up **globally**, which cannot be correct: + * KERSA, GORO, BABILE and BURE each occur in several different zones with different LOCALITY_NOs. + * A global name lookup silently picked the first, i.e. filed a real invoice against whichever tax + * jurisdiction happened to sort first. Resolution here is strictly hierarchical — each level is + * searched only within the rows its parent already selected. + * + * Open Admin Data identifiers (`ET14`, `ET0407`, …) are unrelated to this code system and must + * never appear in an EIMS payload; nothing in this module can emit one, since every returned value + * comes from a numeric column of the Ministry sheet. + */ + +export interface MorGeoCodes { + /** COUNTRY_NO as a string — `BuyerDetails.Country`. */ + Country: string; + /** PARISH_NO as a string — `BuyerDetails.Region`. */ + Region: string; + /** CITY_NO as a string — `BuyerDetails.City`. MoR calls the zone level "City". */ + City: string; + /** LOCALITY_NO as a string — `BuyerDetails.Wereda`. */ + Wereda: string; +} + +export interface MorAddressInput { + country?: string | null; + region?: string | null; + zone?: string | null; + woreda?: string | null; +} + +type Level = "country" | "region" | "zone" | "woreda"; + +/** Which tuple slots hold the name and the code at each level. */ +const SLOTS: Record = { + country: { name: 1, no: 0, column: "COUNTRY_NAME" }, + region: { name: 3, no: 2, column: "PARISH_NAME" }, + zone: { name: 5, no: 4, column: "CITY_NAME" }, + woreda: { name: 7, no: 6, column: "LOCALITY_DESC" }, +}; + +/** + * One normalized form for both sides of every comparison. Deliberately conservative: it removes + * differences that cannot change which jurisdiction is meant (case, stray and repeated whitespace, + * hyphen/slash/parenthesis/apostrophe punctuation, combining accents) and nothing else. There is + * no fuzzy or edit-distance matching anywhere in this module — a near-miss must fail loudly rather + * than file an invoice against a neighbouring woreda. + * + * " FAAFAN ZONE " -> "FAAFAN ZONE" + * "Wal-Mera" -> "WAL MERA" + * "Ber'ano" -> "BERANO" + * "ZONE 1 (AYSSAITA)"-> "ZONE 1 AYSSAITA" + */ +const normalizeCache = new Map(); +export function normalizeName(value: string | null | undefined): string { + const raw = value ?? ""; + const hit = normalizeCache.get(raw); + if (hit !== undefined) return hit; + const normalized = raw + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toUpperCase() + .replace(/['\u2018\u2019`]/g, "") + .replace(/[^A-Z0-9]+/g, " ") + .trim(); + normalizeCache.set(raw, normalized); + return normalized; +} + +/** + * Reviewed spelling differences between what EDR/e-Trade store and what the Ministry sheet calls + * the same place. Every entry is scoped to the administrative level it applies to, and to its + * parent where the name is not unique nationwide — so an alias can never reach across into another + * region's jurisdiction. `from`/`to` are compared normalized, so casing and spacing here are + * cosmetic. + * + * Add an entry only after confirming the two names are the same place in the Ministry sheet. This + * is the only sanctioned place for spelling compatibility; `mor-locations.data.ts` stays verbatim. + */ +interface MorAlias { + level: Exclude; + /** Parent scope, normalized-compared. Omit a level to leave the alias unscoped at that level. */ + region?: string; + zone?: string; + from: string; + to: string; +} + +const ALIASES: MorAlias[] = [ + // e-Trade and the customer portal both spell the Somali zone "Fafen"; MoR spells it "FAAFAN + // ZONE". Confirmed same zone (CITY_NO 31) — this is the buyer that first exposed the whole + // fabricated-code problem. + { level: "zone", region: "SOMALI", from: "Fafen", to: "FAAFAN ZONE" }, + // MoR's own capital of that zone is "JIJIGA"; every other source spells it "Jigjiga". + { + level: "woreda", + region: "SOMALI", + zone: "FAAFAN ZONE", + from: "Jigjiga", + to: "JIJIGA", + }, +]; + +/** + * MoR suffixes many zone labels with " ZONE" ("JIMMA ZONE", "FAAFAN ZONE", "SITI ZONE") while EDR + * stores the bare name. Retrying the suffixed spelling is an exact match against a second candidate + * string, scoped to the already-resolved region — not fuzzy matching — and it removes a long tail + * of otherwise hand-maintained aliases. Applied to the zone level only: locality suffixes + * ("WOREDA", "TOWN ADMINISTRATION") are not mechanical and could select a different place. + */ +const zoneSuffixCandidates = (normalized: string): string[] => + normalized.endsWith(" ZONE") ? [] : [`${normalized} ZONE`]; + +export class MorGeoMappingError extends BadRequestException { + constructor(code: "EIMS_GEO_MAPPING_FAILED" | "EIMS_GEO_AMBIGUOUS", message: string) { + super({ code, message }); + } +} + +/** Renders the address being resolved for an error message. No customer-identifying data. */ +const describe = (input: MorAddressInput): string => + `country="${input.country ?? ""}", region="${input.region ?? ""}", ` + + `zone="${input.zone ?? ""}", woreda="${input.woreda ?? ""}"`; + +function matchLevel( + rows: MorLocationTuple[], + level: Level, + raw: string | null | undefined, + parents: { region?: string; zone?: string }, + input: MorAddressInput, +): { no: number; rows: MorLocationTuple[] } { + const { name: nameSlot, no: noSlot, column } = SLOTS[level]; + const wanted = normalizeName(raw); + + const candidates: string[] = []; + if (wanted) { + candidates.push(wanted); + for (const alias of ALIASES) { + if (alias.level !== level) continue; + if (alias.region && normalizeName(alias.region) !== parents.region) continue; + if (alias.zone && normalizeName(alias.zone) !== parents.zone) continue; + if (normalizeName(alias.from) === wanted) candidates.push(normalizeName(alias.to)); + } + if (level === "zone") candidates.push(...zoneSuffixCandidates(wanted)); + } + + let matched: MorLocationTuple[] = []; + for (const candidate of candidates) { + matched = rows.filter((row) => normalizeName(row[nameSlot] as string) === candidate); + if (matched.length > 0) break; + } + + // A company record that already holds the MoR code itself resolves too — but only when that code + // genuinely exists at this level under this parent. An unvalidated numeric pass-through is how a + // wrong code reaches MoR without anything noticing. + if (matched.length === 0 && /^[0-9]{1,6}$/.test((raw ?? "").trim())) { + const asCode = Number((raw ?? "").trim()); + matched = rows.filter((row) => row[noSlot] === asCode); + } + + if (matched.length === 0) { + throw new MorGeoMappingError( + "EIMS_GEO_MAPPING_FAILED", + `EIMS geographic mapping failed: no MoR ${column} match for ${describe(input)}.`, + ); + } + + const distinct = [...new Set(matched.map((row) => row[noSlot] as number))]; + if (distinct.length > 1) { + throw new MorGeoMappingError( + "EIMS_GEO_AMBIGUOUS", + `EIMS geographic mapping is ambiguous: MoR ${column} "${(raw ?? "").trim()}" matches ` + + `${distinct.length} different codes (${distinct.sort((a, b) => a - b).join(", ")}) for ` + + `${describe(input)}. Correct the company address or the MoR reference data; an ambiguous ` + + "location is never filed.", + ); + } + + return { no: distinct[0], rows: matched }; +} + +/** + * Resolves the full hierarchy, or throws a `BadRequestException` naming the level that failed. + * + * Never guesses and never returns a partial result: an unknown or ambiguous location must stop the + * filing here, locally, before any MoR request and before an EIMS counter is consumed. + */ +export function resolveMorGeo( + input: MorAddressInput, + rows: MorLocationTuple[] = MOR_LOCATIONS, +): MorGeoCodes { + if (rows.length === 0) { + throw new MorGeoMappingError( + "EIMS_GEO_MAPPING_FAILED", + "EIMS geographic mapping failed: the MoR location master is empty. Generate it with " + + "`pnpm --filter @edr/freight-api eims:import-locations `.", + ); + } + + // `companies.country` defaults to 'Ethiopia' and is often left blank on older rows; blank means + // domestic here, exactly as the column default says. A *named* foreign country is resolved like + // any other and fails if MoR does not list it — it is never quietly filed as Ethiopia. + const country = (input.country ?? "").trim() || "Ethiopia"; + + const inCountry = matchLevel(rows, "country", country, {}, input); + const inRegion = matchLevel(inCountry.rows, "region", input.region, {}, input); + const regionScope = normalizeName(inRegion.rows[0][SLOTS.region.name] as string); + const inZone = matchLevel(inRegion.rows, "zone", input.zone, { region: regionScope }, input); + const zoneScope = normalizeName(inZone.rows[0][SLOTS.zone.name] as string); + const inWoreda = matchLevel( + inZone.rows, + "woreda", + input.woreda, + { + region: regionScope, + zone: zoneScope, + }, + input, + ); + + return { + Country: String(inCountry.no), + Region: String(inRegion.no), + City: String(inZone.no), + Wereda: String(inWoreda.no), + }; +} + +/** Non-throwing variant for callers that already have a working fallback (the seller identity). */ +export function tryResolveMorGeo( + input: MorAddressInput, + rows: MorLocationTuple[] = MOR_LOCATIONS, +): MorGeoCodes | null { + try { + return resolveMorGeo(input, rows); + } catch { + return null; + } +} diff --git a/apps/edr-freight-api/src/config/mor-locations.data.ts b/apps/edr-freight-api/src/config/mor-locations.data.ts new file mode 100644 index 000000000..f55cf4ad5 --- /dev/null +++ b/apps/edr-freight-api/src/config/mor-locations.data.ts @@ -0,0 +1,1181 @@ +/** + * GENERATED FILE — do not hand-edit. + * + * MoR EIMS location master (`EIMS_COUNTRY_REGION_VW`), the Ministry's own geographic reference data. + * Regenerate from a supplied workbook with: + * + * pnpm --filter @edr/freight-api eims:import-locations + * + * Values are reproduced verbatim from the Ministry sheet — original spelling, original casing, + * original numbering. Nothing here is cleaned up or renumbered: this file is the traceable copy of + * the source. Spelling compatibility between EDR/e-Trade names and MoR names belongs in + * `mor-location.resolver.ts`'s normalization and alias layer, never here. + */ + +/** `[COUNTRY_NO, COUNTRY_NAME, PARISH_NO, PARISH_NAME, CITY_NO, CITY_NAME, LOCALITY_NO, LOCALITY_DESC]` */ +export type MorLocationTuple = [number, string, number, string, number, string, number, string]; + +export const MOR_LOCATIONS: MorLocationTuple[] = [ + [70, "Ethiopia", 2, "OROMIA", 1, "DEBUB MIRAB SHOA", 401, "WELISO"], + [70, "Ethiopia", 2, "OROMIA", 1, "DEBUB MIRAB SHOA", 402, "ILLU"], + [70, "Ethiopia", 2, "OROMIA", 1, "DEBUB MIRAB SHOA", 403, "AMMEYA"], + [70, "Ethiopia", 2, "OROMIA", 1, "DEBUB MIRAB SHOA", 404, "WENCHI"], + [70, "Ethiopia", 2, "OROMIA", 1, "DEBUB MIRAB SHOA", 405, "BECHO"], + [70, "Ethiopia", 2, "OROMIA", 1, "DEBUB MIRAB SHOA", 406, "TOLE"], + [70, "Ethiopia", 2, "OROMIA", 1, "DEBUB MIRAB SHOA", 407, "DAWO"], + [70, "Ethiopia", 2, "OROMIA", 1, "DEBUB MIRAB SHOA", 408, "KOKIR"], + [70, "Ethiopia", 2, "OROMIA", 1, "DEBUB MIRAB SHOA", 409, "KERSANA KONDALTIT"], + [70, "Ethiopia", 2, "OROMIA", 1, "DEBUB MIRAB SHOA", 601, "ALEMGENA CITY ADMIN"], + [70, "Ethiopia", 2, "OROMIA", 1, "DEBUB MIRAB SHOA", 602, "WELISO CITY ADMIN"], + [70, "Ethiopia", 2, "OROMIA", 1, "DEBUB MIRAB SHOA", 748, "GORO"], + [70, "Ethiopia", 2, "OROMIA", 1, "DEBUB MIRAB SHOA", 749, "SEDEN SODO"], + [70, "Ethiopia", 2, "OROMIA", 1, "DEBUB MIRAB SHOA", 750, "SODO DACHE"], + [70, "Ethiopia", 2, "OROMIA", 1, "DEBUB MIRAB SHOA", 897, "KERSA MALIMA"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 3, "KUYU"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 410, "WONCHI GIDA"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 411, "SULULTA MULA"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 412, "BEREH ALELTU"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 413, "KIMBIBIT"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 414, "WEREJERSO"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 415, "GRAR JERSO"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 416, "DERA"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 417, "DEGEM"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 418, "HADEBU ABOTE"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 419, "YAYA GULELENA DELI"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 420, "ABICHUNA GNEA"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 614, "FITCHE CITY ADMIN"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 615, "SENDAFA CITY ADMIN"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 751, "ALELTU"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 752, "DEBIRELIBANOS"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 753, "JIDDO"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 754, "WUCHALE"], + [70, "Ethiopia", 2, "OROMIA", 3, "BORENA", 4, "YABELO"], + [70, "Ethiopia", 2, "OROMIA", 3, "BORENA", 422, "TELITELE"], + [70, "Ethiopia", 2, "OROMIA", 3, "BORENA", 424, "ARARO"], + [70, "Ethiopia", 2, "OROMIA", 3, "BORENA", 425, "DIRE"], + [70, "Ethiopia", 2, "OROMIA", 3, "BORENA", 426, "MOYALE"], + [70, "Ethiopia", 2, "OROMIA", 3, "BORENA", 603, "YABELO CITY ADMIN"], + [70, "Ethiopia", 2, "OROMIA", 3, "BORENA", 679, "MIYO"], + [70, "Ethiopia", 2, "OROMIA", 3, "BORENA", 681, "BORBOR"], + [70, "Ethiopia", 2, "OROMIA", 3, "BORENA", 682, "DILLO"], + [70, "Ethiopia", 2, "OROMIA", 3, "BORENA", 1173, "GOMOLE"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 5, "LIBEN"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 428, "WADERA"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 429, "ODO SHAKISO"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 430, "BORE"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 431, "URAGA"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 432, "ADOLA"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 622, "NEGELE CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 684, "ANA HAMBELA"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 685, "DAMA"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 686, "GIRJA"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 687, "SEBBA BORU"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 688, "GORO DOLA"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 689, "ANA SORA"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 755, "ADOLA CITY ADMIN"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 756, "ANNA SORA"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 757, "GORO DOLO"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 759, "SABA BORU"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 6, "GUTO WAYU"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 434, "ABAY CHEMEN"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 435, "JIMMA ARJO"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 436, "GIDA KIREMU"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 437, "SIBU SIRE"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 438, "LIMU"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 439, "NUNU KUMBA"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 440, "WAMA BONEYA"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 441, "AMURU"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 442, "HORO"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 443, "IBENTU"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 444, "GUDURU"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 445, "DIGA"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 446, "GUDEYA BILA"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 447, "SASIGA"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 448, "JARTE JARDEGA"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 449, "JIMMA GENETI"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 450, "ABE DONGORO"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 451, "LEKA DULECHA"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 452, "JIMMA RARE"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 591, "BILA SEYO"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 616, "NEKEMTE"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 705, "WAMA AGELO"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 706, "GUTO GIDDA"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 707, "EBANTU"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 708, "KIRAMU"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 709, "GOBBU SEYYO"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 710, "WAYYU TUQA"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 760, "BONEYYA BOSHE"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 761, "GIDA"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 762, "HARO LIMMU"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 763, "KIRAMU "], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 7, "SEYYO"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 19, "GIMBI"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 454, "ANFILO"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 455, "AYRA GULISO"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 456, "ALEM TEFERI"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 457, "LALO ASBI"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 458, "NEJO"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 459, "BOJI"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 460, "LALO KLE"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 461, "HARU"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 462, "DALE SEDI"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 463, "GENJI"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 464, "JIMMA HARO"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 465, "GAO DALE"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 466, "MENESIBU"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 467, "NOLEKABA"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 468, "BEGI"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 469, "GIDAMI"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 470, "YUBIDO"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 471, "JARISO"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 472, "HAWA WELEL"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 604, "GIMBI CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 764, "AYIRA "], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 765, "BABBO"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 766, "BODJI COQORSA"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 767, "BODJI DIRMAJI"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 768, "GULLISO"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 769, "HOMA"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 770, "KILTU KARRA"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 771, "KONDALA"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 772, "NEJO CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 7, "MIRAB HARARGE", 8, "CHIRO"], + [70, "Ethiopia", 2, "OROMIA", 7, "MIRAB HARARGE", 453, "TULO"], + [70, "Ethiopia", 2, "OROMIA", 7, "MIRAB HARARGE", 473, "MESO"], + [70, "Ethiopia", 2, "OROMIA", 7, "MIRAB HARARGE", 474, "KUNI"], + [70, "Ethiopia", 2, "OROMIA", 7, "MIRAB HARARGE", 475, "HABRO"], + [70, "Ethiopia", 2, "OROMIA", 7, "MIRAB HARARGE", 476, "DOBA"], + [70, "Ethiopia", 2, "OROMIA", 7, "MIRAB HARARGE", 477, "MESELA"], + [70, "Ethiopia", 2, "OROMIA", 7, "MIRAB HARARGE", 478, "DARO LEBU"], + [70, "Ethiopia", 2, "OROMIA", 7, "MIRAB HARARGE", 479, "GUBA KORCHA"], + [70, "Ethiopia", 2, "OROMIA", 7, "MIRAB HARARGE", 480, "ANCHER"], + [70, "Ethiopia", 2, "OROMIA", 7, "MIRAB HARARGE", 481, "BOKE"], + [70, "Ethiopia", 2, "OROMIA", 7, "MIRAB HARARGE", 605, "CHIRO CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 7, "MIRAB HARARGE", 773, "BEDDESSA CITY ADMIN"], + [70, "Ethiopia", 2, "OROMIA", 7, "MIRAB HARARGE", 774, "GEMMECHIS"], + [70, "Ethiopia", 2, "OROMIA", 7, "MIRAB HARARGE", 775, "HAWI GUDINA"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 9, "HAROMAYA"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 482, "KERSA"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 483, "META"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 484, "DEDER"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 485, "KOMBOLCHA"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 486, "GURSUM"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 487, "MELKA BELO"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 488, "JARSO"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 489, "GURAWA"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 490, "FEDISS"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 491, "GOLE ODA"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 492, "MAYU"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 493, "GORO GUTU"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 494, "BEDENO"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 495, "BABILE"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 496, "KORFA CHELE"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 617, "AWEDAY CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 696, "HAROMAYA CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 697, "DEDER CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 698, "CHINAKSEN"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 699, "MIDAGA"], + [70, "Ethiopia", 2, "OROMIA", 9, "ROBE", 10, "ASSIGNED IN THE FUTURE"], + [70, "Ethiopia", 2, "OROMIA", 12, "MIRAB ARSII", 632, "ADEBA"], + [70, "Ethiopia", 2, "OROMIA", 12, "MIRAB ARSII", 633, "DODOLA"], + [70, "Ethiopia", 2, "OROMIA", 12, "MIRAB ARSII", 635, "KOKOSA"], + [70, "Ethiopia", 2, "OROMIA", 12, "MIRAB ARSII", 636, "QORE"], + [70, "Ethiopia", 2, "OROMIA", 12, "MIRAB ARSII", 637, "GEDEB ASASA"], + [70, "Ethiopia", 2, "OROMIA", 12, "MIRAB ARSII", 638, "SHALA"], + [70, "Ethiopia", 2, "OROMIA", 12, "MIRAB ARSII", 639, "KOFALE"], + [70, "Ethiopia", 2, "OROMIA", 12, "MIRAB ARSII", 640, "SHASHEMANE"], + [70, "Ethiopia", 2, "OROMIA", 12, "MIRAB ARSII", 641, "ARSI NEGELLE"], + [70, "Ethiopia", 2, "OROMIA", 12, "MIRAB ARSII", 642, "SIRARO"], + [70, "Ethiopia", 2, "OROMIA", 12, "MIRAB ARSII", 778, "DODOLA CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 12, "MIRAB ARSII", 779, "NENSEBO "], + [70, "Ethiopia", 2, "OROMIA", 12, "MIRAB ARSII", 780, "SHASHEMENE CITY ADM"], + [70, "Ethiopia", 2, "OROMIA", 13, "QELLEM WOLLEGA", 643, "ANFILO"], + [70, "Ethiopia", 2, "OROMIA", 13, "QELLEM WOLLEGA", 644, "DALE SADI"], + [70, "Ethiopia", 2, "OROMIA", 13, "QELLEM WOLLEGA", 645, "DALE WABERA"], + [70, "Ethiopia", 2, "OROMIA", 13, "QELLEM WOLLEGA", 646, "GAWO DALLE"], + [70, "Ethiopia", 2, "OROMIA", 13, "QELLEM WOLLEGA", 647, "GIDAMI"], + [70, "Ethiopia", 2, "OROMIA", 13, "QELLEM WOLLEGA", 648, "HAWA GELAN"], + [70, "Ethiopia", 2, "OROMIA", 13, "QELLEM WOLLEGA", 649, "JIMA HORO"], + [70, "Ethiopia", 2, "OROMIA", 13, "QELLEM WOLLEGA", 650, "LALO KILE"], + [70, "Ethiopia", 2, "OROMIA", 13, "QELLEM WOLLEGA", 651, "SAYO"], + [70, "Ethiopia", 2, "OROMIA", 13, "QELLEM WOLLEGA", 652, "YEMALOGI WELEL"], + [70, "Ethiopia", 2, "OROMIA", 13, "QELLEM WOLLEGA", 781, "DEMBIDOLLO CITY ADMIN"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 21, "ADAMA"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 377, "AKAKI"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 378, "LUME"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 379, "ADA"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 380, "SHASHEMENE"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 381, "ADAMITULU"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 382, "DUGDA BORA"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 383, "ARSI NEGELE"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 384, "BOSET"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 385, "FENTALE"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 386, "SIRARO"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 387, "GMBCHU"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 606, "ADAMA CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 618, "BISHOFTU CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 620, "SHASHEMENE CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 700, "MOJO TOWN ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 701, "BATU TOWN ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 702, "METEHARA CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 703, "BORA"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 704, "GELEAN CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 782, "BORA "], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 783, "DUGDA "], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 784, "LIBAN CHUKALA"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 785, "METAHARA CITY ADMIN"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 1, "AMBO"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 388, "DANDI"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 389, "CHELIA"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 390, "DIRRE INCHINI"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 391, "JELDU"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 392, "EJERE"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 393, "NONO"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 394, "BAKO"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 395, "MEDAKEY"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 396, "WELMERA"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 397, "DANO"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 398, "GINDEBERT"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 399, "ADA BERGA"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 400, "META ROBI"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 607, "AMBO CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 608, "HOLLETA CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 861, "ABUNA GINDEBERET"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 862, "ILFATA"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 863, "ILU GELAN"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 864, "JIBAT"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 865, "TOKKE KUTAYE"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 497, "DEDO"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 498, "GERA"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 499, "GOMA"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 500, "LIMUKOSA"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 501, "MANA"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 502, "OMO NADA"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 503, "KERSA"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 504, "SEKA CHOKORSA"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 505, "TIRO AFETA"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 506, "LIMU SEKA"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 507, "SOKORU"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 508, "SIGMO"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 509, "SETEMA"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 609, "JIMMA CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 884, "AGARO CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 886, "GUMAY"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 887, "NONNO BENJA"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 888, "SHABE"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 510, "METU"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 514, "HALU BURE"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 516, "DARIMU"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 519, "ALLE"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 520, "YAYU HURUMU"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 521, "SALE NONO"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 522, "ALGE SECHI"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 610, "METU CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 666, "BILO NOPHA"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 667, "DORENI"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 668, "DIDO"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 690, "HURUMU"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 691, "BURE"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 692, "BECHO"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 693, "NONNO"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 890, "BURE "], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 891, "HALU "], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 892, "YAYU "], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 523, "ROBE"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 524, "DODOTANA SRE"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 525, "MERTI"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 526, "GEDEB"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 527, "GOLOLICHA"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 528, "TIYO"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 529, "LIMUNA BILBILO"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 530, "MUNESA"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 531, "TENA"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 532, "SERU"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 533, "KOFELE"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 534, "JEJU"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 535, "CHOLE"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 536, "ASAKO"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 537, "DIKSI"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 538, "DIGELUNA TIYU"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 539, "ZWAY DUGDA"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 540, "HETOSA"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 541, "LODEHETOSA"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 542, "SUDE"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 543, "AMINIYA"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 544, "SHIRKA"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 612, "ASSELA CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 654, "GUNA"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 669, "BOKOJI CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 670, "ENKOLO WABE"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 671, "SIRE"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 672, "BALE GASARA"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 893, "DODOTA"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 1196, "SHENAN KOLU"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 545, "SINANA DINISHO"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 546, "GOBA"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 547, "DODOLA"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 548, "ADABA"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 549, "GINIR"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 550, "BERBERE"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 551, "AGARFA"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 552, "GASERA"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 553, "GORO"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 554, "GOLOLCHA"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 555, "MENA ANGATU"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 556, "KOKOSA"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 557, "NANSEBO"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 558, "SEWENA"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 559, "LEGEHIDA"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 560, "RAYTU"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 561, "GURADAMOLE"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 562, "MEDAWELABU"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 621, "ROBE CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 673, "GOBBA CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 674, "DAWWE SERAR"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 675, "HARENNA BULUK"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 676, "DINSHO"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 677, "DAWWE KACHEN"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 894, "SINANA "], + [70, "Ethiopia", 2, "OROMIA", 83, "MIRAB ARSI", 634, "NANSEBO"], + [70, "Ethiopia", 2, "OROMIA", 84, "Horo guduru Wollegga", 656, "ABBE DONGORO"], + [70, "Ethiopia", 2, "OROMIA", 84, "Horo guduru Wollegga", 657, "SHAMBU CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 84, "Horo guduru Wollegga", 658, "HORO"], + [70, "Ethiopia", 2, "OROMIA", 84, "Horo guduru Wollegga", 659, "JIMMA RARE"], + [70, "Ethiopia", 2, "OROMIA", 84, "Horo guduru Wollegga", 660, "ABBAY CHOMAN"], + [70, "Ethiopia", 2, "OROMIA", 84, "Horo guduru Wollegga", 661, "GUDURU"], + [70, "Ethiopia", 2, "OROMIA", 84, "Horo guduru Wollegga", 662, "IMBABO"], + [70, "Ethiopia", 2, "OROMIA", 84, "Horo guduru Wollegga", 663, "JIMMA GENET"], + [70, "Ethiopia", 2, "OROMIA", 84, "Horo guduru Wollegga", 664, "AMURU JARTE"], + [70, "Ethiopia", 2, "OROMIA", 84, "Horo guduru Wollegga", 665, "JARTE JARDEGA"], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 619, "DUKEM TOWN ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 909, "Akaki woreda "], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 910, "BARAH"], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 911, "BURAYYU TOWN ADMIN"], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 912, "Dukem Town Administration"], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 913, "Gelan Town Administration"], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 914, "Holleta Town Administration"], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 915, "LEGATAFO TOWN ADMIN"], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 916, "MULLO"], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 971, "Sabeta Hawas"], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 972, "Sebeta Town Administration"], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 973, "Sendafa Town Administration"], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 974, "Sululta "], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 975, "SULULTA TOWN ADMIN"], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 976, "Wal-Mera"], + [70, "Ethiopia", 2, "OROMIA", 94, "OROMIYA-BUNO-BEDELE-ZONE", 247, "BORICHA"], + [70, "Ethiopia", 2, "OROMIA", 94, "OROMIYA-BUNO-BEDELE-ZONE", 885, "CHORA"], + [70, "Ethiopia", 2, "OROMIA", 95, "OROMIYA-WEST-GUJI-ZONE", 421, "GELANA"], + [70, "Ethiopia", 2, "OROMIA", 95, "OROMIYA-WEST-GUJI-ZONE", 423, "BULAHORA"], + [70, "Ethiopia", 2, "OROMIA", 95, "OROMIYA-WEST-GUJI-ZONE", 427, "ABEYA"], + [70, "Ethiopia", 2, "OROMIA", 95, "OROMIYA-WEST-GUJI-ZONE", 433, "KERRCHA"], + [70, "Ethiopia", 2, "OROMIA", 95, "OROMIYA-WEST-GUJI-ZONE", 678, "DUGDA DAWWA"], + [70, "Ethiopia", 2, "OROMIA", 95, "OROMIYA-WEST-GUJI-ZONE", 680, "MELKA SODDA"], + [70, "Ethiopia", 2, "OROMIA", 95, "OROMIYA-WEST-GUJI-ZONE", 758, "HAMBELA WAMNA"], + [70, "Ethiopia", 2, "OROMIA", 95, "OROMIYA-WEST-GUJI-ZONE", 1194, "SURO BARGUDA"], + [70, "Ethiopia", 2, "OROMIA", 98, "BUNO BEDELE ZONE", 511, "BEDELE"], + [70, "Ethiopia", 2, "OROMIA", 98, "BUNO BEDELE ZONE", 512, "GECHI"], + [70, "Ethiopia", 2, "OROMIA", 98, "BUNO BEDELE ZONE", 513, "BORECHA"], + [70, "Ethiopia", 2, "OROMIA", 98, "BUNO BEDELE ZONE", 515, "CHORA"], + [70, "Ethiopia", 2, "OROMIA", 98, "BUNO BEDELE ZONE", 517, "DIDESSA"], + [70, "Ethiopia", 2, "OROMIA", 98, "BUNO BEDELE ZONE", 518, "DIGA"], + [70, "Ethiopia", 2, "OROMIA", 98, "BUNO BEDELE ZONE", 613, "CHEWAKA"], + [70, "Ethiopia", 2, "OROMIA", 98, "BUNO BEDELE ZONE", 694, "DABO HANA"], + [70, "Ethiopia", 2, "OROMIA", 98, "BUNO BEDELE ZONE", 695, "MEKKO"], + [70, "Ethiopia", 2, "OROMIA", 98, "BUNO BEDELE ZONE", 889, "BEDELLE CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 100, "WEST GUJI ZONE", 1199, "BIRBIRSA KOJOWA"], + [70, "Ethiopia", 2, "OROMIA", 106, "OROMIA", 2, "ALEMGENA"], + [70, "Ethiopia", 2, "OROMIA", 121, "SHEGER CITY", 1288, "MERTU 1"], + [70, "Ethiopia", 2, "OROMIA", 123, "EAST BORENA ZONE", 1291, "OBORSO"], + [70, "Ethiopia", 2, "OROMIA", 124, "BISHOFTU CITY ADMIN", 1292, "DIBAYU SUBCITY"], + [70, "Ethiopia", 2, "OROMIA", 125, "SHEGER CITY ADMIN", 1293, "MELKA NONO SUB CITY"], + [70, "Ethiopia", 3, "TIGRAY", 56, "MEAKELLAY", 13, "TAHITY MAICHEW"], + [70, "Ethiopia", 3, "TIGRAY", 56, "MEAKELLAY", 342, "DEGUA TENBEN"], + [70, "Ethiopia", 3, "TIGRAY", 56, "MEAKELLAY", 343, "LAILAY MICHEW"], + [70, "Ethiopia", 3, "TIGRAY", 56, "MEAKELLAY", 344, "TANKWA ABERGELE"], + [70, "Ethiopia", 3, "TIGRAY", 56, "MEAKELLAY", 345, "KOLA TENBEN"], + [70, "Ethiopia", 3, "TIGRAY", 56, "MEAKELLAY", 346, "NAIDAR ADAT"], + [70, "Ethiopia", 3, "TIGRAY", 56, "MEAKELLAY", 347, "MEREB LEHE"], + [70, "Ethiopia", 3, "TIGRAY", 56, "MEAKELLAY", 348, "WEREI"], + [70, "Ethiopia", 3, "TIGRAY", 56, "MEAKELLAY", 349, "ADOWA TOWN ADMIN"], + [70, "Ethiopia", 3, "TIGRAY", 56, "MEAKELLAY", 350, "AHIFEROM"], + [70, "Ethiopia", 3, "TIGRAY", 56, "MEAKELLAY", 592, "AXUM TOWN ADMIN"], + [70, "Ethiopia", 3, "TIGRAY", 56, "MEAKELLAY", 626, "GETER ADWA"], + [70, "Ethiopia", 3, "TIGRAY", 56, "MEAKELLAY", 627, "ABIY ADI TOWN ADMIN"], + [70, "Ethiopia", 3, "TIGRAY", 57, "MISRAK", 351, "GULOMECHA"], + [70, "Ethiopia", 3, "TIGRAY", 57, "MISRAK", 352, "SAISI TSAIDA IMBA"], + [70, "Ethiopia", 3, "TIGRAY", 57, "MISRAK", 353, "GANTA AFESHUM"], + [70, "Ethiopia", 3, "TIGRAY", 57, "MISRAK", 354, "ASTIBI WENBERTA"], + [70, "Ethiopia", 3, "TIGRAY", 57, "MISRAK", 355, "HAWZEN"], + [70, "Ethiopia", 3, "TIGRAY", 57, "MISRAK", 356, "WKIRO"], + [70, "Ethiopia", 3, "TIGRAY", 57, "MISRAK", 357, "EROB"], + [70, "Ethiopia", 3, "TIGRAY", 57, "MISRAK", 593, "ADIGRAT TOWN ADMIN"], + [70, "Ethiopia", 3, "TIGRAY", 57, "MISRAK", 628, "KLETE AWLAELO"], + [70, "Ethiopia", 3, "TIGRAY", 58, "MIRABAWI", 358, "KWAFTA HUMERA"], + [70, "Ethiopia", 3, "TIGRAY", 58, "MIRABAWI", 359, "TSEGEDE"], + [70, "Ethiopia", 3, "TIGRAY", 58, "MIRABAWI", 360, "WELKAYIT"], + [70, "Ethiopia", 3, "TIGRAY", 58, "MIRABAWI", 594, "HUMERA TOWN ADMIN"], + [70, "Ethiopia", 3, "TIGRAY", 59, "SEMEN MIRABAWI", 361, "LAILAY ADIYABO"], + [70, "Ethiopia", 3, "TIGRAY", 59, "SEMEN MIRABAWI", 362, "MDEBAY ZANA"], + [70, "Ethiopia", 3, "TIGRAY", 59, "SEMEN MIRABAWI", 363, "TAHITAY ADIYABO"], + [70, "Ethiopia", 3, "TIGRAY", 59, "SEMEN MIRABAWI", 364, "TSELEMTI"], + [70, "Ethiopia", 3, "TIGRAY", 59, "SEMEN MIRABAWI", 365, "ASEGEDE TSIMBLA"], + [70, "Ethiopia", 3, "TIGRAY", 59, "SEMEN MIRABAWI", 595, "SHIRARO TOWN ADMIN"], + [70, "Ethiopia", 3, "TIGRAY", 59, "SEMEN MIRABAWI", 596, "TAHITAY KORARO"], + [70, "Ethiopia", 3, "TIGRAY", 59, "SEMEN MIRABAWI", 597, "INDASILASSIE TOWN ADMIN"], + [70, "Ethiopia", 3, "TIGRAY", 70, "DEBUBAWI ZONE", 366, "INIDAMOHENI"], + [70, "Ethiopia", 3, "TIGRAY", 70, "DEBUBAWI ZONE", 367, "INDERTA"], + [70, "Ethiopia", 3, "TIGRAY", 70, "DEBUBAWI ZONE", 368, "SAMRA SAHARTI"], + [70, "Ethiopia", 3, "TIGRAY", 70, "DEBUBAWI ZONE", 369, "ALAJE"], + [70, "Ethiopia", 3, "TIGRAY", 70, "DEBUBAWI ZONE", 370, "ALAMATA TOWN ADMIN"], + [70, "Ethiopia", 3, "TIGRAY", 70, "DEBUBAWI ZONE", 371, "OFLA"], + [70, "Ethiopia", 3, "TIGRAY", 70, "DEBUBAWI ZONE", 372, "RAYA AZEBO"], + [70, "Ethiopia", 3, "TIGRAY", 70, "DEBUBAWI ZONE", 598, "MAICHEW TOWN ADMIN"], + [70, "Ethiopia", 3, "TIGRAY", 70, "DEBUBAWI ZONE", 599, "KOREM TOWN ADMINISTRATION"], + [70, "Ethiopia", 3, "TIGRAY", 70, "DEBUBAWI ZONE", 629, "GETER ALAMATA"], + [70, "Ethiopia", 3, "TIGRAY", 70, "DEBUBAWI ZONE", 630, "HINTALO WAJRAT"], + [70, "Ethiopia", 3, "TIGRAY", 70, "DEBUBAWI ZONE", 1193, "SAMRE SEHARTI"], + [70, "Ethiopia", 3, "TIGRAY", 71, "MEKELE", 373, "SEMENE WORDA"], + [70, "Ethiopia", 3, "TIGRAY", 71, "MEKELE", 611, "DEBUB WEREDA"], + [70, "Ethiopia", 3, "TIGRAY", 71, "MEKELE", 623, "KUHA WOREDA"], + [70, "Ethiopia", 3, "TIGRAY", 71, "MEKELE", 879, "AIDER"], + [70, "Ethiopia", 3, "TIGRAY", 71, "MEKELE", 880, "ADI HAKI"], + [70, "Ethiopia", 3, "TIGRAY", 71, "MEKELE", 881, "HADNET"], + [70, "Ethiopia", 3, "TIGRAY", 71, "MEKELE", 882, "HAWULTI"], + [70, "Ethiopia", 3, "TIGRAY", 71, "MEKELE", 883, "KEDAMAY WOYANE"], + [70, "Ethiopia", 3, "TIGRAY", 99, "SOUTH EASTERN ZONE", 1192, "SAMRE SEHARTI"], + [70, "Ethiopia", 4, "AFAR", 42, "ZONE 1 (AYSSAITA)", 15, "AYSSAITA"], + [70, "Ethiopia", 4, "AFAR", 42, "ZONE 1 (AYSSAITA)", 22, "AFAMBO"], + [70, "Ethiopia", 4, "AFAR", 42, "ZONE 1 (AYSSAITA)", 23, "DUBTI"], + [70, "Ethiopia", 4, "AFAR", 42, "ZONE 1 (AYSSAITA)", 24, "MILE"], + [70, "Ethiopia", 4, "AFAR", 42, "ZONE 1 (AYSSAITA)", 25, "ELIDAAR"], + [70, "Ethiopia", 4, "AFAR", 42, "ZONE 1 (AYSSAITA)", 26, "CHIFRA"], + [70, "Ethiopia", 4, "AFAR", 42, "ZONE 1 (AYSSAITA)", 816, "ADAER"], + [70, "Ethiopia", 4, "AFAR", 42, "ZONE 1 (AYSSAITA)", 817, "KURI"], + [70, "Ethiopia", 4, "AFAR", 42, "ZONE 1 (AYSSAITA)", 1190, "SEMERA - LOGIA CITY ADMINISTRATION"], + [70, "Ethiopia", 4, "AFAR", 43, "ZONE 2 (ABBALLA)", 27, "BERHALLE"], + [70, "Ethiopia", 4, "AFAR", 43, "ZONE 2 (ABBALLA)", 28, "ABBALLA"], + [70, "Ethiopia", 4, "AFAR", 43, "ZONE 2 (ABBALLA)", 29, "KUNEBBA"], + [70, "Ethiopia", 4, "AFAR", 43, "ZONE 2 (ABBALLA)", 30, "AFFDERA"], + [70, "Ethiopia", 4, "AFAR", 43, "ZONE 2 (ABBALLA)", 31, "IREBTI"], + [70, "Ethiopia", 4, "AFAR", 43, "ZONE 2 (ABBALLA)", 32, "MEGALLE"], + [70, "Ethiopia", 4, "AFAR", 43, "ZONE 2 (ABBALLA)", 33, "DALLOL"], + [70, "Ethiopia", 4, "AFAR", 43, "ZONE 2 (ABBALLA)", 818, "BIDU"], + [70, "Ethiopia", 4, "AFAR", 44, "ZONE 3 (SIDAHAFAGE)", 34, "AMMIBARA"], + [70, "Ethiopia", 4, "AFAR", 44, "ZONE 3 (SIDAHAFAGE)", 35, "GEWANE"], + [70, "Ethiopia", 4, "AFAR", 44, "ZONE 3 (SIDAHAFAGE)", 36, "BUREMUDAYTU"], + [70, "Ethiopia", 4, "AFAR", 44, "ZONE 3 (SIDAHAFAGE)", 37, "AWASH"], + [70, "Ethiopia", 4, "AFAR", 44, "ZONE 3 (SIDAHAFAGE)", 38, "DULECHA"], + [70, "Ethiopia", 4, "AFAR", 44, "ZONE 3 (SIDAHAFAGE)", 39, "GACHENNE"], + [70, "Ethiopia", 4, "AFAR", 44, "ZONE 3 (SIDAHAFAGE)", 819, "ARGOBA"], + [70, "Ethiopia", 4, "AFAR", 44, "ZONE 3 (SIDAHAFAGE)", 1191, "AWASH CITY ADMINISTRATION"], + [70, "Ethiopia", 4, "AFAR", 64, "ZONE4 (KELEWAN)", 40, "KELEWAN"], + [70, "Ethiopia", 4, "AFAR", 64, "ZONE4 (KELEWAN)", 41, "YALLO"], + [70, "Ethiopia", 4, "AFAR", 64, "ZONE4 (KELEWAN)", 42, "AWRRA"], + [70, "Ethiopia", 4, "AFAR", 64, "ZONE4 (KELEWAN)", 43, "TERRU"], + [70, "Ethiopia", 4, "AFAR", 64, "ZONE4 (KELEWAN)", 44, "EWAA"], + [70, "Ethiopia", 4, "AFAR", 64, "ZONE4 (KELEWAN)", 908, "GULINA"], + [70, "Ethiopia", 4, "AFAR", 65, "ZONE 5 (DALLIFAGEA)", 45, "DALLIFAGEA"], + [70, "Ethiopia", 4, "AFAR", 65, "ZONE 5 (DALLIFAGEA)", 46, "ARTUMMA"], + [70, "Ethiopia", 4, "AFAR", 65, "ZONE 5 (DALLIFAGEA)", 47, "SEMUROBI"], + [70, "Ethiopia", 4, "AFAR", 65, "ZONE 5 (DALLIFAGEA)", 48, "DEWWEA"], + [70, "Ethiopia", 4, "AFAR", 65, "ZONE 5 (DALLIFAGEA)", 49, "TELALAK"], + [70, "Ethiopia", 4, "AFAR", 65, "ZONE 5 (DALLIFAGEA)", 866, "HADELE ELA"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 39, "ASOSA", 16, "ASSOSA"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 39, "ASOSA", 163, "KURMUK"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 39, "ASOSA", 164, "BANBASI"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 39, "ASOSA", 165, "MENGE"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 39, "ASOSA", 166, "SHERKOLE"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 39, "ASOSA", 167, "KOMOSHA"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 39, "ASOSA", 168, "ODABUL DIGLU"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 39, "ASOSA", 169, "PAWE SPECIAL WORDA"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 39, "ASOSA", 170, "MAO KOMO SPECIAL WORDA"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 39, "ASOSA", 1201, "ASOSSA TOWN ADMINISTRATION"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 40, "KAMSH", 171, "KAMASH"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 40, "KAMSH", 172, "SEDAL"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 40, "KAMSH", 173, "BELODJAGANFOY"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 40, "KAMSH", 174, "AGALOMITI"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 40, "KAMSH", 175, "YASO"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 40, "KAMSH", 1202, "KAMASHI TOWN ADMINSTRATION"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 41, "METEKEL", 176, "MAMDURA"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 41, "METEKEL", 177, "DANGUR"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 41, "METEKEL", 178, "WENBERA"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 41, "METEKEL", 179, "DUBATE"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 41, "METEKEL", 180, "BULEN"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 41, "METEKEL", 181, "GUBA"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 41, "METEKEL", 815, "PAWEIE"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 41, "METEKEL", 1203, "GELGEL BELES TOWN ADMINSTRATION"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 89, "NO ZONE/MAO KOMO", 896, "MAO KOMO"], + [70, "Ethiopia", 6, "SOMALI", 30, "SITI ZONE", 12, "SHINELE"], + [70, "Ethiopia", 6, "SOMALI", 30, "SITI ZONE", 197, "DENBEL"], + [70, "Ethiopia", 6, "SOMALI", 30, "SITI ZONE", 198, "ERAR"], + [70, "Ethiopia", 6, "SOMALI", 30, "SITI ZONE", 199, "AYSHA"], + [70, "Ethiopia", 6, "SOMALI", 30, "SITI ZONE", 200, "AFDEM"], + [70, "Ethiopia", 6, "SOMALI", 30, "SITI ZONE", 201, "MIESO"], + [70, "Ethiopia", 6, "SOMALI", 30, "SITI ZONE", 811, "MA'YS"], + [70, "Ethiopia", 6, "SOMALI", 30, "SITI ZONE", 898, "NO WOREDA-1412"], + [70, "Ethiopia", 6, "SOMALI", 30, "SITI ZONE", 899, "NO WOREDA-1413"], + [70, "Ethiopia", 6, "SOMALI", 30, "SITI ZONE", 986, "Hadagale"], + [70, "Ethiopia", 6, "SOMALI", 30, "SITI ZONE", 988, "HADAGALE WOREDA"], + [70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 190, "JIJIGA"], + [70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 191, "KEBRI BEYAH"], + [70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 192, "AWUBERE"], + [70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 193, "HARSHIN"], + [70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 194, "BABILE"], + [70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 195, "GURSUM"], + [70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 196, "EJERSAGORO"], + [70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 980, "Tuli Guled"], + [70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 981, "Goljano"], + [70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 983, "GOLJANO WOREDA"], + [70, "Ethiopia", 6, "SOMALI", 32, "KORAHI", 202, "KEBRIDAHAR"], + [70, "Ethiopia", 6, "SOMALI", 32, "KORAHI", 203, "WEYIN"], + [70, "Ethiopia", 6, "SOMALI", 32, "KORAHI", 204, "SHYKOSH"], + [70, "Ethiopia", 6, "SOMALI", 32, "KORAHI", 205, "SHILABO"], + [70, "Ethiopia", 6, "SOMALI", 32, "KORAHI", 900, "DOBOWEYN"], + [70, "Ethiopia", 6, "SOMALI", 32, "KORAHI", 996, "Marsin"], + [70, "Ethiopia", 6, "SOMALI", 33, "SHABEELE ZONE", 206, "GODE"], + [70, "Ethiopia", 6, "SOMALI", 33, "SHABEELE ZONE", 207, "KELAFO"], + [70, "Ethiopia", 6, "SOMALI", 33, "SHABEELE ZONE", 208, "MUSTEHIL"], + [70, "Ethiopia", 6, "SOMALI", 33, "SHABEELE ZONE", 209, "FERFEF"], + [70, "Ethiopia", 6, "SOMALI", 33, "SHABEELE ZONE", 210, "DANAN"], + [70, "Ethiopia", 6, "SOMALI", 33, "SHABEELE ZONE", 211, "ADADA"], + [70, "Ethiopia", 6, "SOMALI", 33, "SHABEELE ZONE", 212, "EMAYBERE"], + [70, "Ethiopia", 6, "SOMALI", 33, "SHABEELE ZONE", 901, "ADADLEY"], + [70, "Ethiopia", 6, "SOMALI", 33, "SHABEELE ZONE", 902, "EAST EMAY"], + [70, "Ethiopia", 6, "SOMALI", 33, "SHABEELE ZONE", 992, "Elwayn"], + [70, "Ethiopia", 6, "SOMALI", 33, "SHABEELE ZONE", 993, "Ber'ano"], + [70, "Ethiopia", 6, "SOMALI", 33, "SHABEELE ZONE", 1184, "ABAGOROW"], + [70, "Ethiopia", 6, "SOMALI", 34, "JARAR ZONE", 213, "DEGAHABUR"], + [70, "Ethiopia", 6, "SOMALI", 34, "JARAR ZONE", 214, "AWARE"], + [70, "Ethiopia", 6, "SOMALI", 34, "JARAR ZONE", 215, "DGAHMEDOW"], + [70, "Ethiopia", 6, "SOMALI", 34, "JARAR ZONE", 216, "GASHAMO"], + [70, "Ethiopia", 6, "SOMALI", 34, "JARAR ZONE", 812, "GUNAGADO"], + [70, "Ethiopia", 6, "SOMALI", 34, "JARAR ZONE", 982, "Birkod"], + [70, "Ethiopia", 6, "SOMALI", 34, "JARAR ZONE", 984, "Daroor"], + [70, "Ethiopia", 6, "SOMALI", 34, "JARAR ZONE", 985, "Ararso"], + [70, "Ethiopia", 6, "SOMALI", 34, "JARAR ZONE", 1014, "YOALE"], + [70, "Ethiopia", 6, "SOMALI", 35, "NOGOB ZONE", 217, "FIK"], + [70, "Ethiopia", 6, "SOMALI", 35, "NOGOB ZONE", 218, "SEGEG"], + [70, "Ethiopia", 6, "SOMALI", 35, "NOGOB ZONE", 219, "HAMERO"], + [70, "Ethiopia", 6, "SOMALI", 35, "NOGOB ZONE", 220, "DUHUN"], + [70, "Ethiopia", 6, "SOMALI", 35, "NOGOB ZONE", 221, "GERBO"], + [70, "Ethiopia", 6, "SOMALI", 35, "NOGOB ZONE", 222, "LGEHAD"], + [70, "Ethiopia", 6, "SOMALI", 35, "NOGOB ZONE", 223, "SELHAD"], + [70, "Ethiopia", 6, "SOMALI", 35, "NOGOB ZONE", 813, "MAYAMULUKO"], + [70, "Ethiopia", 6, "SOMALI", 35, "NOGOB ZONE", 903, "GAEBO"], + [70, "Ethiopia", 6, "SOMALI", 35, "NOGOB ZONE", 991, "Qubi"], + [70, "Ethiopia", 6, "SOMALI", 35, "NOGOB ZONE", 1168, "CEELWAYNE"], + [70, "Ethiopia", 6, "SOMALI", 36, "DOLLO ZONE", 224, "WARDER"], + [70, "Ethiopia", 6, "SOMALI", 36, "DOLLO ZONE", 225, "GELAD"], + [70, "Ethiopia", 6, "SOMALI", 36, "DOLLO ZONE", 226, "DANOD"], + [70, "Ethiopia", 6, "SOMALI", 36, "DOLLO ZONE", 227, "BOK"], + [70, "Ethiopia", 6, "SOMALI", 36, "DOLLO ZONE", 989, "Daratole"], + [70, "Ethiopia", 6, "SOMALI", 37, "AFDER", 228, "ELKERE"], + [70, "Ethiopia", 6, "SOMALI", 37, "AFDER", 229, "HARGELE"], + [70, "Ethiopia", 6, "SOMALI", 37, "AFDER", 230, "BARE"], + [70, "Ethiopia", 6, "SOMALI", 37, "AFDER", 231, "JERETI"], + [70, "Ethiopia", 6, "SOMALI", 37, "AFDER", 232, "IMAYEGELEBED"], + [70, "Ethiopia", 6, "SOMALI", 37, "AFDER", 233, "GORO BAKAKSA"], + [70, "Ethiopia", 6, "SOMALI", 37, "AFDER", 234, "GURDAMOLE"], + [70, "Ethiopia", 6, "SOMALI", 37, "AFDER", 814, "DOLOBAY"], + [70, "Ethiopia", 6, "SOMALI", 37, "AFDER", 904, "WEST EMAY"], + [70, "Ethiopia", 6, "SOMALI", 37, "AFDER", 994, "Qarsadula"], + [70, "Ethiopia", 6, "SOMALI", 37, "AFDER", 995, "Raso"], + [70, "Ethiopia", 6, "SOMALI", 37, "AFDER", 1186, "GODGOD"], + [70, "Ethiopia", 6, "SOMALI", 38, "LIBON", 235, "FILTU"], + [70, "Ethiopia", 6, "SOMALI", 38, "LIBON", 236, "DOLADEW"], + [70, "Ethiopia", 6, "SOMALI", 38, "LIBON", 237, "MOYALE"], + [70, "Ethiopia", 6, "SOMALI", 38, "LIBON", 238, "KEAHORE/HADAT/"], + [70, "Ethiopia", 6, "SOMALI", 38, "LIBON", 987, "Mubarak"], + [70, "Ethiopia", 6, "SOMALI", 38, "LIBON", 1015, "DEKA SUFTI"], + [70, "Ethiopia", 6, "SOMALI", 38, "LIBON", 1189, "BOQAL-MAY"], + [70, "Ethiopia", 6, "SOMALI", 93, "ERER ZONE", 1171, "FIIQ"], + [70, "Ethiopia", 7, "GAMBELA", 27, "NUER", 17, "GAMBELLA"], + [70, "Ethiopia", 7, "GAMBELA", 27, "NUER", 182, "EITANG"], + [70, "Ethiopia", 7, "GAMBELA", 27, "NUER", 183, "JIKAWO"], + [70, "Ethiopia", 7, "GAMBELA", 27, "NUER", 184, "AKOBO"], + [70, "Ethiopia", 7, "GAMBELA", 27, "NUER", 808, "LARE"], + [70, "Ethiopia", 7, "GAMBELA", 27, "NUER", 809, "MAKUEY"], + [70, "Ethiopia", 7, "GAMBELA", 27, "NUER", 979, "Wantuar"], + [70, "Ethiopia", 7, "GAMBELA", 28, "AGNUWAK", 185, "ABOBO"], + [70, "Ethiopia", 7, "GAMBELA", 28, "AGNUWAK", 186, "GOG"], + [70, "Ethiopia", 7, "GAMBELA", 28, "AGNUWAK", 188, "DIMA"], + [70, "Ethiopia", 7, "GAMBELA", 28, "AGNUWAK", 189, "JOR"], + [70, "Ethiopia", 7, "GAMBELA", 28, "AGNUWAK", 810, "GAMBELA"], + [70, "Ethiopia", 7, "GAMBELA", 87, "MEZENGER", 187, "GODERE"], + [70, "Ethiopia", 7, "GAMBELA", 87, "MEZENGER", 977, "MENGESH"], + [70, "Ethiopia", 7, "GAMBELA", 88, "NO ZONE/ETANG", 895, "ETANG "], + [70, "Ethiopia", 7, "GAMBELA", 90, "GAMBELLA TOWN ADMIN", 978, "GAMBELLA TOWN ADMIN"], + [70, "Ethiopia", 8, "HARARI", 60, "NO ZONE - HARARI", 18, "NO WOREDA-9"], + [70, "Ethiopia", 9, "SNNPRS", 10, "HALABA ZONE", 600, "ALABA CITY ADMINISTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 10, "HALABA ZONE", 1247, "WERA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 10, "HALABA ZONE", 1248, "ATOTI OULO WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 10, "HALABA ZONE", 1249, "WERA DIJO WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 11, "HAWASSA CITY ADMIN", 624, "NO WOREDA-193"], + [70, "Ethiopia", 9, "SNNPRS", 11, "HAWASSA CITY ADMIN", 776, "HAWASSA CITY ADM"], + [70, "Ethiopia", 9, "SNNPRS", 11, "HAWASSA CITY ADMIN", 777, "TULA SUB TOWN ADMIN"], + [70, "Ethiopia", 9, "SNNPRS", 18, "GEDIO", 248, "WENAGO"], + [70, "Ethiopia", 9, "SNNPRS", 18, "GEDIO", 249, "YIRGACHEFE"], + [70, "Ethiopia", 9, "SNNPRS", 18, "GEDIO", 250, "COCHERE"], + [70, "Ethiopia", 9, "SNNPRS", 18, "GEDIO", 251, "BULE"], + [70, "Ethiopia", 9, "SNNPRS", 18, "GEDIO", 566, "DILLA TOWN ADMIN"], + [70, "Ethiopia", 9, "SNNPRS", 18, "GEDIO", 567, "YIRGACHEFE TOWN ADMIN"], + [70, "Ethiopia", 9, "SNNPRS", 18, "GEDIO", 720, "GEDEB"], + [70, "Ethiopia", 9, "SNNPRS", 18, "GEDIO", 721, "DILLA ZURIA"], + [70, "Ethiopia", 9, "SNNPRS", 18, "GEDIO", 722, "WERABE CITY ADMINISTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 18, "GEDIO", 790, "DILA VICINITY"], + [70, "Ethiopia", 9, "SNNPRS", 18, "GEDIO", 1232, "GEDEB TOWN ADMINSTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 18, "GEDIO", 1233, "CHELELEKTU TOWN ADMINSTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 18, "GEDIO", 1234, "CHORSO MAZORIA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 18, "GEDIO", 1235, "RAPE WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 19, "KENBATA TENBARO", 252, "KEDIDA GAMELA"], + [70, "Ethiopia", 9, "SNNPRS", 19, "KENBATA TENBARO", 253, "QACHA BIRA"], + [70, "Ethiopia", 9, "SNNPRS", 19, "KENBATA TENBARO", 254, "ANGACHA"], + [70, "Ethiopia", 9, "SNNPRS", 19, "KENBATA TENBARO", 255, "TENBARO"], + [70, "Ethiopia", 9, "SNNPRS", 19, "KENBATA TENBARO", 586, "DURAME TOWN ADMIN"], + [70, "Ethiopia", 9, "SNNPRS", 19, "KENBATA TENBARO", 655, "DOYOGENA"], + [70, "Ethiopia", 9, "SNNPRS", 19, "KENBATA TENBARO", 727, "HADERO TOWN"], + [70, "Ethiopia", 9, "SNNPRS", 19, "KENBATA TENBARO", 728, "DEMBOYA"], + [70, "Ethiopia", 9, "SNNPRS", 19, "KENBATA TENBARO", 791, "HADARO & TUNTO"], + [70, "Ethiopia", 9, "SNNPRS", 19, "KENBATA TENBARO", 1036, "SHINSHICHO TOWN"], + [70, "Ethiopia", 9, "SNNPRS", 19, "KENBATA TENBARO", 1226, "ADILO ZURIA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 256, "SODO VICINTY"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 257, "DAMOT GALE"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 258, "DAMOT WOYIDA"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 259, "BOLOSO SORE"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 260, "OFFA"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 261, "KINDO KOYISHA"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 262, "HUMBO"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 570, "SODO TOWN ADMIN"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 579, "BODITY TOWN ADMIN"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 580, "ARCKA CITY ADMINISTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 743, "DAMOT SORE"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 744, "DAMOT FULASE"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 745, "BELESO BONBE"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 746, "KINDO DIDAYO"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 747, "DIGUNA FANGO"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 792, "BULOSO BOMBE"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 793, "DAGUNA FANGO"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 794, "DAMOT FULAS"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 795, "KINDO DEDAYE"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 1240, "TEBELA TOWN ADMINSTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 1241, "GUNUNO HAMUS TOWN ADMINSTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 1242, "GESUBA TOWN ADMINSTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 1243, "HOBICHA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 1244, "ABELA ABAYA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 1245, "BAYIRA KOYISHA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 1246, "KAWA KOYISHA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 263, "LIMU"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 264, "MISHA"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 265, "SORO"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 266, "BADEWACHO"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 267, "GIBE"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 268, "SHASHEGO"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 269, "DUNA"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 585, "HOSAINA TOWN ADMINISTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 724, "MIRAB BADEWACHO"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 725, "GOMBORA"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 726, "MISRAK BADEWACHO"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 796, "ANLEMO"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 797, "EAST BADEWACHO"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 798, "GONBERA"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 1035, "SHONE TOWN"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 1250, "GIMIBECHU TOWN ADMINSTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 1251, "JAJURA TOWN ADMINTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 1252, "AMEKA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 1253, "SIRARO BADEWACHO WEREDA"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 1254, "MIRAB SORO WOREDA "], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 270, "ARBAMINCH ZURIA"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 271, "MIRAB ABAYA"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 272, "BONKE"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 273, "KEMBA TWON ADMINSTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 274, "CHENCHA ZURIA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 275, "DEREMALO"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 276, "KUCHA"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 277, "GOFA ZURIA"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 280, "BOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 281, "DITA"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 581, "ARBAMINCH TOWN ADMINISTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 719, "DENBA GOFA"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 800, "GIZE GOFA"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 1218, "CHENCHA TOWN ADMINSTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 1219, "SELAMBER TOWN ADMINSTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 1220, "KEMBA ZURIYA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 1221, "GARDA MARTA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 1222, "GERESE WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 1223, "GACHO BABA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 1224, "KOGOTA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 1225, "KUCHA ALFA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 1277, "BIRIBIR TOWN ADMINSTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 25, "DEBUB OMO", 300, "BAKO GAZER"], + [70, "Ethiopia", 9, "SNNPRS", 25, "DEBUB OMO", 301, "HAMER"], + [70, "Ethiopia", 9, "SNNPRS", 25, "DEBUB OMO", 302, "GELEB"], + [70, "Ethiopia", 9, "SNNPRS", 25, "DEBUB OMO", 303, "SELAMAGO"], + [70, "Ethiopia", 9, "SNNPRS", 25, "DEBUB OMO", 304, "GELILA"], + [70, "Ethiopia", 9, "SNNPRS", 25, "DEBUB OMO", 305, "BENA TSEMAY WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 25, "DEBUB OMO", 587, "JINKA CITY ADMINISTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 25, "DEBUB OMO", 714, "DASENECH"], + [70, "Ethiopia", 9, "SNNPRS", 25, "DEBUB OMO", 715, "DEBUB ARI"], + [70, "Ethiopia", 9, "SNNPRS", 25, "DEBUB OMO", 716, "SEMEN ARI"], + [70, "Ethiopia", 9, "SNNPRS", 25, "DEBUB OMO", 802, "GNANGATOM"], + [70, "Ethiopia", 9, "SNNPRS", 25, "DEBUB OMO", 803, "KURAZ"], + [70, "Ethiopia", 9, "SNNPRS", 25, "DEBUB OMO", 804, "MALE"], + [70, "Ethiopia", 9, "SNNPRS", 25, "DEBUB OMO", 805, "NORTH ARI"], + [70, "Ethiopia", 9, "SNNPRS", 25, "DEBUB OMO", 806, "SOUTH ARI"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 328, "SANKURA"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 329, "AZERNET BERBERE"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 330, "ALICHO WERIRO"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 331, "DALOCHA"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 332, "SILTE"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 333, "LANFRO"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 739, "MIRAB AZERNET BERBERE"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 740, "HULBAREG"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 741, "WERABE CITY ADMINISTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 742, "MISRAK AZERNET BERBERE"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 874, "EAST AZERNET BERBERE"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 875, "WELBAREG"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 876, "WERABE TOWN ADM"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 877, "WEST AZERNET BERBERE"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 1227, "KIBET TOWN ADMINSTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 1228, "TORA TOWN ADMINSTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 1229, "MITO WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 1230, "MISRAK SILTI WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 1275, "TEST"], + [70, "Ethiopia", 9, "SNNPRS", 69, "SEGEN AREA PEOPLE ZONE", 334, "AMARO"], + [70, "Ethiopia", 9, "SNNPRS", 69, "SEGEN AREA PEOPLE ZONE", 335, "BURJI"], + [70, "Ethiopia", 9, "SNNPRS", 69, "SEGEN AREA PEOPLE ZONE", 336, "DERASHE"], + [70, "Ethiopia", 9, "SNNPRS", 69, "SEGEN AREA PEOPLE ZONE", 337, "KONSO"], + [70, "Ethiopia", 9, "SNNPRS", 69, "SEGEN AREA PEOPLE ZONE", 341, "ALABA"], + [70, "Ethiopia", 9, "SNNPRS", 69, "SEGEN AREA PEOPLE ZONE", 990, "ALE"], + [70, "Ethiopia", 9, "SNNPRS", 69, "SEGEN AREA PEOPLE ZONE", 1040, "SEGEN TOWN"], + [70, "Ethiopia", 9, "SNNPRS", 91, "SPECIAL WOREDA", 338, "BASKETO"], + [70, "Ethiopia", 9, "SNNPRS", 91, "SPECIAL WOREDA", 339, "KONTA"], + [70, "Ethiopia", 9, "SNNPRS", 91, "SPECIAL WOREDA", 340, "YEM"], + [70, "Ethiopia", 9, "SNNPRS", 91, "SPECIAL WOREDA", 878, "ALABA CITY ADM"], + [70, "Ethiopia", 9, "SNNPRS", 101, "GOFA ZONE", 278, "UBA DEBERSEHAY WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 101, "GOFA ZONE", 279, "MELO KOZA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 101, "GOFA ZONE", 282, "ZALA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 101, "GOFA ZONE", 582, "SAWLA CITY ADMINISTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 101, "GOFA ZONE", 717, "OYDA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 101, "GOFA ZONE", 718, "GEZE GOFA"], + [70, "Ethiopia", 9, "SNNPRS", 101, "GOFA ZONE", 799, "DEMBA GOFA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 101, "GOFA ZONE", 1231, "MELO GADA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 101, "GOFA ZONE", 1279, "LEHA TOWN ADMINSTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 103, "KONSO ZONE", 1236, "KARAT TOWN ADMINSTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 103, "KONSO ZONE", 1237, "KENNA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 103, "KONSO ZONE", 1238, "SEGEN ZURIYA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 103, "KONSO ZONE", 1239, "KARAT ZURIA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 109, "SNNPRS", 1286, "AMAYA CITY ADMINSTRATION "], + [70, "Ethiopia", 9, "SNNPRS", 109, "SNNPRS", 1287, "CHIDA CITY ADMINSTRATION "], + [70, "Ethiopia", 10, "DIRE DAWA", 61, "NO ZONE DIRE DAWA", 20, "WOREDA 1"], + [70, "Ethiopia", 10, "DIRE DAWA", 61, "NO ZONE DIRE DAWA", 374, "WOREDA 2"], + [70, "Ethiopia", 10, "DIRE DAWA", 61, "NO ZONE DIRE DAWA", 375, "WOREDA 3"], + [70, "Ethiopia", 10, "DIRE DAWA", 61, "NO ZONE DIRE DAWA", 376, "WOREDA 4"], + [70, "Ethiopia", 10, "DIRE DAWA", 61, "NO ZONE DIRE DAWA", 631, "NO WOREDA-1100"], + [70, "Ethiopia", 11, "AMAHARA", 45, "BAHIDAR SPECIAL", 14, "NO WOREDA-4"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 62, "DESSIE KETEMA"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 63, "DESSIE ZURIA"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 64, "ALBIKO"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 65, "KOMBOLCHA"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 66, "KALU"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 67, "KUTABER"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 68, "TEHULEDERIA"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 69, "AMBASEL"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 70, "WOREBABO"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 71, "JAMMA"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 72, "WORIELUE"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 73, "LEGAMBO"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 74, "TENTA"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 75, "MEKDELA"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 76, "WOGEDIE"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 78, "KELALA"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 79, "SAINT"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 822, "ARGOBA"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 823, "BORENA"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 824, "LEGEHADI"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 825, "MEHAL SAYINT"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 1021, "HAIK TOWN ADMINSTRATION"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 84, "GONDER KETMA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 85, "GONDER ZURIA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 86, "DEMBIA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 87, "ALFA TAKUSA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 88, "METEMA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 89, "KOLA BELESA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 90, "DEGA BELESA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 91, "CHILGA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 92, "LAI-ARMACHIHO"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 93, "TEGDE"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 94, "ARMACHIHO"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 95, "WEGERA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 96, "DABAT"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 97, "DEBARK"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 98, "QUARA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 99, "BEYEDA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 100, "JANAMORA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 101, "ADIARKAYI"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 826, "ALEFA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 827, "DEBARK VICINITY"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 828, "GENDA WEHA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 829, "TACH ARMACHIHO"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 830, "TAKUSA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 831, "TELEMET"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 832, "WEST ARMACHIHO"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 905, "EAST BELESA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 906, "WEST BELESA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 1019, "METEMA YOHANNES TOWN ADMINISTRATION"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 132, "GOZAMIN"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 133, "D-MARKKOS KETEMA"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 134, "MACHAKEL"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 135, "DEBRE ELIAS"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 136, "BIBUGN"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 137, "AWABEL"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 138, "BASOLIBEN"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 139, "DEJEN"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 140, "EINEMAY"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 141, "DEBAY TILATGEN"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 142, "EINARJ ENAWGA"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 143, "GONCHA-SISO ENESE"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 144, "HULT EIJU EINESIA"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 145, "EINEBSIE SAR MIDER"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 146, "SHEBEL BERENTA"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 833, "ANEDED"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 834, "MOTTA"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 835, "SENAN"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 1029, "DEJEN TOWN ADMINISTRATION"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 77, "DEBRESINA"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 112, "D-BIRHAN KETEMA"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 113, "BASONA WERANA"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 114, "ANGOLALA"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 115, "ASAGRT"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 116, "ANKOBER"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 117, "AGERMARIAM-KETEM"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 118, "BEREHEET"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 119, "EFRATANA GIDME"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 120, "ANTSOKIYANA GEMZ"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 121, "GAIRA-KEYA"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 122, "GESHIE"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 123, "MERAHABETIE"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 124, "KEWET"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 125, "LALO MAMA MEDIR"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 126, "MORETINA -JIRU"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 127, "MINJARINA-SHENKORA"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 128, "MIDANA WOREMO"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 129, "ENSARONA-WAYU"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 130, "TARMA-BER"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 131, "MOJANA WEDERA"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 836, "MENZ GIERA MEDER"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 837, "MENZ LALU MEDER"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 838, "MENZ MAMA MEDER"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 839, "SHOWA ROBIT"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 840, "SIYADBERENA WAYU"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 907, "MENZ KEYA GEBRIEL"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 1016, "MEHALMEDA TOWN ADMINSTRATION"], + [70, "Ethiopia", 11, "AMAHARA", 51, "NORTH WOLLO", 50, "WOLDIA KETEMA"], + [70, "Ethiopia", 11, "AMAHARA", 51, "NORTH WOLLO", 51, "GUBALAFTO"], + [70, "Ethiopia", 11, "AMAHARA", 51, "NORTH WOLLO", 52, "DELANTA DAWINT"], + [70, "Ethiopia", 11, "AMAHARA", 51, "NORTH WOLLO", 53, "KOBO"], + [70, "Ethiopia", 11, "AMAHARA", 51, "NORTH WOLLO", 54, "HABRU"], + [70, "Ethiopia", 11, "AMAHARA", 51, "NORTH WOLLO", 55, "BUGINA"], + [70, "Ethiopia", 11, "AMAHARA", 51, "NORTH WOLLO", 56, "GIDAN"], + [70, "Ethiopia", 11, "AMAHARA", 51, "NORTH WOLLO", 57, "MEKIET"], + [70, "Ethiopia", 11, "AMAHARA", 51, "NORTH WOLLO", 58, "WADELA"], + [70, "Ethiopia", 11, "AMAHARA", 51, "NORTH WOLLO", 841, "DAWUNT"], + [70, "Ethiopia", 11, "AMAHARA", 51, "NORTH WOLLO", 842, "DELANTA"], + [70, "Ethiopia", 11, "AMAHARA", 51, "NORTH WOLLO", 843, "KOBO VICINITY"], + [70, "Ethiopia", 11, "AMAHARA", 51, "NORTH WOLLO", 844, "LALIBELA"], + [70, "Ethiopia", 11, "AMAHARA", 51, "NORTH WOLLO", 845, "LASTA"], + [70, "Ethiopia", 11, "AMAHARA", 51, "NORTH WOLLO", 1020, "MERSA TOWN ADMINISTRATION"], + [70, "Ethiopia", 11, "AMAHARA", 52, "SOUTH GONDER", 102, "DEBRETABOR KETEMA"], + [70, "Ethiopia", 11, "AMAHARA", 52, "SOUTH GONDER", 103, "FARTA"], + [70, "Ethiopia", 11, "AMAHARA", 52, "SOUTH GONDER", 104, "ESTIE"], + [70, "Ethiopia", 11, "AMAHARA", 52, "SOUTH GONDER", 105, "DERRA"], + [70, "Ethiopia", 11, "AMAHARA", 52, "SOUTH GONDER", 106, "LAI-GAINT"], + [70, "Ethiopia", 11, "AMAHARA", 52, "SOUTH GONDER", 107, "TACH-GAINT"], + [70, "Ethiopia", 11, "AMAHARA", 52, "SOUTH GONDER", 108, "SIMADA"], + [70, "Ethiopia", 11, "AMAHARA", 52, "SOUTH GONDER", 109, "FOGERA"], + [70, "Ethiopia", 11, "AMAHARA", 52, "SOUTH GONDER", 110, "LIBOKEMEKEM"], + [70, "Ethiopia", 11, "AMAHARA", 52, "SOUTH GONDER", 111, "EBINAT"], + [70, "Ethiopia", 11, "AMAHARA", 52, "SOUTH GONDER", 846, "EAST ESTIE"], + [70, "Ethiopia", 11, "AMAHARA", 52, "SOUTH GONDER", 847, "WEST ESTIE"], + [70, "Ethiopia", 11, "AMAHARA", 52, "SOUTH GONDER", 848, "WORETA"], + [70, "Ethiopia", 11, "AMAHARA", 52, "SOUTH GONDER", 1023, "ADIS ZEMEN TOWN ADMINISTRATION"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 147, "BAHIRDAR ZURIA"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 148, "YILMANA-DENSA"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 149, "MECHA"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 150, "ACHEFER"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 151, "SEKELA"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 152, "BURIE"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 153, "WONBERMA"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 154, "JABI-TIHNAN"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 155, "QUARIT"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 156, "DENBECHA"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 157, "DEGADAMOT"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 849, "BAHIRDAR TOWN ADMINIS."], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 850, "BURIE VICINITY"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 851, "FENOTE SELAM"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 852, "GONJI KOLELA"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 853, "NORTH ACHEFER"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 854, "SOUTH ACHEFER"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 1025, "DEMBECHA TOWN ADMINSTRATION"], + [70, "Ethiopia", 11, "AMAHARA", 54, "OROMIA", 80, "JILIENA TIMUGA"], + [70, "Ethiopia", 11, "AMAHARA", 54, "OROMIA", 81, "ARTUMA FARSI"], + [70, "Ethiopia", 11, "AMAHARA", 54, "OROMIA", 82, "DAWA-CHEFA"], + [70, "Ethiopia", 11, "AMAHARA", 54, "OROMIA", 83, "BATI"], + [70, "Ethiopia", 11, "AMAHARA", 54, "OROMIA", 855, "DEWIHAREWA"], + [70, "Ethiopia", 11, "AMAHARA", 54, "OROMIA", 856, "KEMISIE TOWN ADMINISTRATION"], + [70, "Ethiopia", 11, "AMAHARA", 54, "OROMIA", 1031, "BATI TOWN ADMINISTRATION"], + [70, "Ethiopia", 11, "AMAHARA", 55, "WAGHIMRA", 59, "SEKOTA"], + [70, "Ethiopia", 11, "AMAHARA", 55, "WAGHIMRA", 60, "DEHANA"], + [70, "Ethiopia", 11, "AMAHARA", 55, "WAGHIMRA", 61, "ZIQUALA"], + [70, "Ethiopia", 11, "AMAHARA", 55, "WAGHIMRA", 857, "ABERGELIE"], + [70, "Ethiopia", 11, "AMAHARA", 55, "WAGHIMRA", 858, "GAZIBELA"], + [70, "Ethiopia", 11, "AMAHARA", 55, "WAGHIMRA", 859, "SEHALA"], + [70, "Ethiopia", 11, "AMAHARA", 55, "WAGHIMRA", 860, "SEKOTA VICINITY"], + [70, "Ethiopia", 11, "AMAHARA", 66, "AWI", 158, "BANJA-SHIKUDAD"], + [70, "Ethiopia", 11, "AMAHARA", 66, "AWI", 159, "GUANGUA"], + [70, "Ethiopia", 11, "AMAHARA", 66, "AWI", 160, "DANGILA"], + [70, "Ethiopia", 11, "AMAHARA", 66, "AWI", 161, "ANKESHA-GUAGUSA"], + [70, "Ethiopia", 11, "AMAHARA", 66, "AWI", 162, "FAGTA LEKOMA"], + [70, "Ethiopia", 11, "AMAHARA", 66, "AWI", 867, "ANKESHA "], + [70, "Ethiopia", 11, "AMAHARA", 66, "AWI", 868, "CHAGNI"], + [70, "Ethiopia", 11, "AMAHARA", 66, "AWI", 869, "DANGELA VICINITY"], + [70, "Ethiopia", 11, "AMAHARA", 66, "AWI", 870, "ENJEBARA TOWN ADMIN"], + [70, "Ethiopia", 11, "AMAHARA", 66, "AWI", 871, "GUAGUSA"], + [70, "Ethiopia", 11, "AMAHARA", 66, "AWI", 872, "JAWI"], + [70, "Ethiopia", 11, "AMAHARA", 66, "AWI", 1032, "ZIGEM"], + [70, "Ethiopia", 12, "NO REGION", 76, "NO ZONE REGION", 653, "NO WOREDA REGION"], + [70, "Ethiopia", 13, "ADDIS ABABA", 14, "ARADA", 568, "NO WOREDA-139"], + [70, "Ethiopia", 13, "ADDIS ABABA", 14, "ARADA", 1056, "WOREDA 1"], + [70, "Ethiopia", 13, "ADDIS ABABA", 14, "ARADA", 1057, "WOREDA 2"], + [70, "Ethiopia", 13, "ADDIS ABABA", 14, "ARADA", 1058, "WOREDA 3"], + [70, "Ethiopia", 13, "ADDIS ABABA", 14, "ARADA", 1059, "WOREDA 4"], + [70, "Ethiopia", 13, "ADDIS ABABA", 14, "ARADA", 1060, "WOREDA 5"], + [70, "Ethiopia", 13, "ADDIS ABABA", 14, "ARADA", 1061, "WOREDA 6"], + [70, "Ethiopia", 13, "ADDIS ABABA", 14, "ARADA", 1062, "WOREDA 7"], + [70, "Ethiopia", 13, "ADDIS ABABA", 14, "ARADA", 1063, "WOREDA 9"], + [70, "Ethiopia", 13, "ADDIS ABABA", 14, "ARADA", 1064, "WOREDA 8"], + [70, "Ethiopia", 13, "ADDIS ABABA", 14, "ARADA", 1065, "WOREDA 10"], + [70, "Ethiopia", 13, "ADDIS ABABA", 15, "ADDIS KETEMA", 569, "NO WOREDA-140"], + [70, "Ethiopia", 13, "ADDIS ABABA", 15, "ADDIS KETEMA", 1042, "WOREDA 2"], + [70, "Ethiopia", 13, "ADDIS ABABA", 15, "ADDIS KETEMA", 1048, "WOREDA 3"], + [70, "Ethiopia", 13, "ADDIS ABABA", 15, "ADDIS KETEMA", 1049, "WOREDA 4"], + [70, "Ethiopia", 13, "ADDIS ABABA", 15, "ADDIS KETEMA", 1050, "WOREDA 5"], + [70, "Ethiopia", 13, "ADDIS ABABA", 15, "ADDIS KETEMA", 1051, "WOREDA 6"], + [70, "Ethiopia", 13, "ADDIS ABABA", 15, "ADDIS KETEMA", 1052, "WOREDA 7"], + [70, "Ethiopia", 13, "ADDIS ABABA", 15, "ADDIS KETEMA", 1053, "WOREDA 9"], + [70, "Ethiopia", 13, "ADDIS ABABA", 15, "ADDIS KETEMA", 1054, "WOREDA 10"], + [70, "Ethiopia", 13, "ADDIS ABABA", 15, "ADDIS KETEMA", 1055, "WOREDA 8"], + [70, "Ethiopia", 13, "ADDIS ABABA", 29, "LIDETA", 571, "NO WOREDA-141"], + [70, "Ethiopia", 13, "ADDIS ABABA", 29, "LIDETA", 1066, "WOREDA 1"], + [70, "Ethiopia", 13, "ADDIS ABABA", 29, "LIDETA", 1067, "WOREDA 2"], + [70, "Ethiopia", 13, "ADDIS ABABA", 29, "LIDETA", 1068, "WOREDA 3"], + [70, "Ethiopia", 13, "ADDIS ABABA", 29, "LIDETA", 1069, "WOREDA 4"], + [70, "Ethiopia", 13, "ADDIS ABABA", 29, "LIDETA", 1070, "WOREDA 5"], + [70, "Ethiopia", 13, "ADDIS ABABA", 29, "LIDETA", 1071, "WOREDA 6"], + [70, "Ethiopia", 13, "ADDIS ABABA", 29, "LIDETA", 1072, "WOREDA 7"], + [70, "Ethiopia", 13, "ADDIS ABABA", 29, "LIDETA", 1073, "WOREDA 9"], + [70, "Ethiopia", 13, "ADDIS ABABA", 29, "LIDETA", 1074, "WOREDA 8"], + [70, "Ethiopia", 13, "ADDIS ABABA", 29, "LIDETA", 1075, "WOREDA 10"], + [70, "Ethiopia", 13, "ADDIS ABABA", 63, "KIRKOS", 572, "NO WOREDA-142"], + [70, "Ethiopia", 13, "ADDIS ABABA", 63, "KIRKOS", 1076, "WOREDA 1"], + [70, "Ethiopia", 13, "ADDIS ABABA", 63, "KIRKOS", 1077, "WOREDA 2"], + [70, "Ethiopia", 13, "ADDIS ABABA", 63, "KIRKOS", 1078, "WOREDA 3"], + [70, "Ethiopia", 13, "ADDIS ABABA", 63, "KIRKOS", 1079, "WOREDA 4"], + [70, "Ethiopia", 13, "ADDIS ABABA", 63, "KIRKOS", 1080, "WOREDA 5"], + [70, "Ethiopia", 13, "ADDIS ABABA", 63, "KIRKOS", 1081, "WOREDA 6"], + [70, "Ethiopia", 13, "ADDIS ABABA", 63, "KIRKOS", 1082, "WOREDA 7"], + [70, "Ethiopia", 13, "ADDIS ABABA", 63, "KIRKOS", 1083, "WOREDA 9"], + [70, "Ethiopia", 13, "ADDIS ABABA", 63, "KIRKOS", 1084, "WOREDA 8"], + [70, "Ethiopia", 13, "ADDIS ABABA", 63, "KIRKOS", 1085, "WOREDA 10"], + [70, "Ethiopia", 13, "ADDIS ABABA", 63, "KIRKOS", 1086, "WOREDA 11"], + [70, "Ethiopia", 13, "ADDIS ABABA", 77, "YEKA", 573, "NO WOREDA-143"], + [70, "Ethiopia", 13, "ADDIS ABABA", 77, "YEKA", 1087, "WOREDA 1"], + [70, "Ethiopia", 13, "ADDIS ABABA", 77, "YEKA", 1088, "WOREDA 2"], + [70, "Ethiopia", 13, "ADDIS ABABA", 77, "YEKA", 1089, "WOREDA 3"], + [70, "Ethiopia", 13, "ADDIS ABABA", 77, "YEKA", 1090, "WOREDA 4"], + [70, "Ethiopia", 13, "ADDIS ABABA", 77, "YEKA", 1091, "WOREDA 5"], + [70, "Ethiopia", 13, "ADDIS ABABA", 77, "YEKA", 1092, "WOREDA 6"], + [70, "Ethiopia", 13, "ADDIS ABABA", 77, "YEKA", 1093, "WOREDA 7"], + [70, "Ethiopia", 13, "ADDIS ABABA", 77, "YEKA", 1094, "WOREDA 9"], + [70, "Ethiopia", 13, "ADDIS ABABA", 77, "YEKA", 1095, "WOREDA 8"], + [70, "Ethiopia", 13, "ADDIS ABABA", 77, "YEKA", 1096, "WOREDA 10"], + [70, "Ethiopia", 13, "ADDIS ABABA", 77, "YEKA", 1097, "WOREDA 11"], + [70, "Ethiopia", 13, "ADDIS ABABA", 77, "YEKA", 1098, "WOREDA 12"], + [70, "Ethiopia", 13, "ADDIS ABABA", 77, "YEKA", 1099, "WOREDA 13"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 574, "NO WOREDA-144"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1100, "WOREDA 1"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1101, "WOREDA 2"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1102, "WOREDA 3"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1103, "WOREDA 4"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1104, "WOREDA 5"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1105, "WOREDA 6"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1106, "WOREDA 7"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1107, "WOREDA 9"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1108, "WOREDA 8"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1109, "WOREDA 10"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1110, "WOREDA 11"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1111, "WOREDA 12"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1112, "WOREDA 13"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1113, "WOREDA 14"], + [70, "Ethiopia", 13, "ADDIS ABABA", 79, "AKAKI KALITI", 575, "NO WOREDA-145"], + [70, "Ethiopia", 13, "ADDIS ABABA", 79, "AKAKI KALITI", 1114, "WOREDA 1"], + [70, "Ethiopia", 13, "ADDIS ABABA", 79, "AKAKI KALITI", 1115, "WOREDA 2"], + [70, "Ethiopia", 13, "ADDIS ABABA", 79, "AKAKI KALITI", 1116, "WOREDA 3"], + [70, "Ethiopia", 13, "ADDIS ABABA", 79, "AKAKI KALITI", 1117, "WOREDA 4"], + [70, "Ethiopia", 13, "ADDIS ABABA", 79, "AKAKI KALITI", 1118, "WOREDA 5"], + [70, "Ethiopia", 13, "ADDIS ABABA", 79, "AKAKI KALITI", 1119, "WOREDA 6"], + [70, "Ethiopia", 13, "ADDIS ABABA", 79, "AKAKI KALITI", 1120, "WOREDA 7"], + [70, "Ethiopia", 13, "ADDIS ABABA", 79, "AKAKI KALITI", 1121, "WOREDA 8"], + [70, "Ethiopia", 13, "ADDIS ABABA", 80, "NEFAS SILK LAFTO", 576, "NO WOREDA-146"], + [70, "Ethiopia", 13, "ADDIS ABABA", 80, "NEFAS SILK LAFTO", 1046, "WOREDA 1"], + [70, "Ethiopia", 13, "ADDIS ABABA", 80, "NEFAS SILK LAFTO", 1122, "WOREDA 2"], + [70, "Ethiopia", 13, "ADDIS ABABA", 80, "NEFAS SILK LAFTO", 1123, "WOREDA 3"], + [70, "Ethiopia", 13, "ADDIS ABABA", 80, "NEFAS SILK LAFTO", 1124, "WOREDA 4"], + [70, "Ethiopia", 13, "ADDIS ABABA", 80, "NEFAS SILK LAFTO", 1125, "WOREDA 5"], + [70, "Ethiopia", 13, "ADDIS ABABA", 80, "NEFAS SILK LAFTO", 1126, "WOREDA 6"], + [70, "Ethiopia", 13, "ADDIS ABABA", 80, "NEFAS SILK LAFTO", 1127, "WOREDA 7"], + [70, "Ethiopia", 13, "ADDIS ABABA", 80, "NEFAS SILK LAFTO", 1128, "WOREDA 9"], + [70, "Ethiopia", 13, "ADDIS ABABA", 80, "NEFAS SILK LAFTO", 1129, "WOREDA 8"], + [70, "Ethiopia", 13, "ADDIS ABABA", 80, "NEFAS SILK LAFTO", 1130, "WOREDA 10"], + [70, "Ethiopia", 13, "ADDIS ABABA", 80, "NEFAS SILK LAFTO", 1131, "WOREDA 11"], + [70, "Ethiopia", 13, "ADDIS ABABA", 80, "NEFAS SILK LAFTO", 1132, "WOREDA 12"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 577, "NO WOREDA-147"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1133, "WOREDA 1"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1134, "WOREDA 2"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1135, "WOREDA 3"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1136, "WOREDA 4"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1137, "WOREDA 5"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1138, "WOREDA 6"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1139, "WOREDA 7"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1140, "WOREDA 9"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1141, "WOREDA 8"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1142, "WOREDA 10"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1143, "WOREDA 11"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1144, "WOREDA 12"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1145, "WOREDA 13"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1146, "WOREDA 14"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1147, "WOREDA 15"], + [70, "Ethiopia", 13, "ADDIS ABABA", 82, "GULLELE", 578, "NO WOREDA-148"], + [70, "Ethiopia", 13, "ADDIS ABABA", 82, "GULLELE", 1148, "WOREDA 1"], + [70, "Ethiopia", 13, "ADDIS ABABA", 82, "GULLELE", 1149, "WOREDA 2"], + [70, "Ethiopia", 13, "ADDIS ABABA", 82, "GULLELE", 1150, "WOREDA 3"], + [70, "Ethiopia", 13, "ADDIS ABABA", 82, "GULLELE", 1151, "WOREDA 4"], + [70, "Ethiopia", 13, "ADDIS ABABA", 82, "GULLELE", 1152, "WOREDA 5"], + [70, "Ethiopia", 13, "ADDIS ABABA", 82, "GULLELE", 1153, "WOREDA 6"], + [70, "Ethiopia", 13, "ADDIS ABABA", 82, "GULLELE", 1154, "WOREDA 7"], + [70, "Ethiopia", 13, "ADDIS ABABA", 82, "GULLELE", 1155, "WOREDA 9"], + [70, "Ethiopia", 13, "ADDIS ABABA", 82, "GULLELE", 1156, "WOREDA 8"], + [70, "Ethiopia", 13, "ADDIS ABABA", 82, "GULLELE", 1157, "WOREDA 10"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 11, "AWASSA VICINITY"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 239, "SHEBEDINO"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 240, "DALE"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 241, "ALETA WONDO"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 242, "AGERESELAM"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 243, "BENSSA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 244, "ARBEGONA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 245, "ARORESSA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 246, "DARRA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 563, "HAWASSA CITY ADMINISTRATION"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 564, "ALETAWONDO TOWN ADMIN"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 565, "YIRGALEM TOWN ADMIN"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 730, "WENDO GENET"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 731, "MELGA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 732, "GORCHE"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 733, "WENSHO"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 734, "LOKA ABAYA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 735, "CHUKO"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 736, "BONA ZURIA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 737, "BURSA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 738, "CHIRE"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 786, "ALETA CHUKO"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 787, "BUNA VICINITY"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 788, "GORECHIE"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 789, "WENISHO"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 1039, "LEKU TOWN"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 1258, "WONDO GENET TOWN ADMINSTRATION"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 1259, "ALETA CHUKO TOWN ADMINSTRATION"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 1260, "HOKO WOREDA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 1261, "HAWELA WOREDA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 1262, "DA'ELA WOREDA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 1263, "DARARA WOREDA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 1264, "TETICHA WOREDA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 1265, "CHIRONE WOREDA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 1266, "BILATE ZURIA WOREDA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 1267, "CHEBE WOREDA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 1268, "BURA WOREDA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 1269, "SHAFAMO WOREDA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 1270, "DARA OTILICHO WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 24, "DAWRO", 295, "MAREQA"], + [70, "Ethiopia", 15, "SOUTH WEST", 24, "DAWRO", 296, "LOMA"], + [70, "Ethiopia", 15, "SOUTH WEST", 24, "DAWRO", 297, "TOCHA"], + [70, "Ethiopia", 15, "SOUTH WEST", 24, "DAWRO", 298, "ZABA GAZO WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 24, "DAWRO", 299, "ESERA"], + [70, "Ethiopia", 15, "SOUTH WEST", 24, "DAWRO", 713, "TERCHA CITY ADMINISTRATION"], + [70, "Ethiopia", 15, "SOUTH WEST", 24, "DAWRO", 1205, "GENA WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 24, "DAWRO", 1211, "DISA WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 24, "DAWRO", 1212, "TARCHA ZURIA WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 24, "DAWRO", 1213, "MARI MANSA WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 24, "DAWRO", 1214, "KECHI WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 306, "GIMBO"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 307, "DECHA"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 308, "CHENA"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 309, "BITA"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 310, "TELLO"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 311, "MENGEO"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 312, "GESHA"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 313, "CHETA"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 314, "SAYILEM"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 315, "GEWATA"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 588, "BONGA CITY ADMINISTRATION"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 807, "ADIYUO"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 1255, "WACHA TOWN ADMINSTRATION"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 1256, "GOBA WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 1257, "SHISHO ENDO WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 1283, "SHISHO ENDO CITY ADMINSTRATION "], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 1284, "DAKA CITY ADMINSTRATION "], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 1285, "AWRADA CITY ADMINSTRATION "], + [70, "Ethiopia", 15, "SOUTH WEST", 46, "BENCH SHEKO ZONE", 316, "BENCH"], + [70, "Ethiopia", 15, "SOUTH WEST", 46, "BENCH SHEKO ZONE", 317, "SHEKO WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 46, "BENCH SHEKO ZONE", 319, "GURA FERDA WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 46, "BENCH SHEKO ZONE", 321, "SHEWA BENCH"], + [70, "Ethiopia", 15, "SOUTH WEST", 46, "BENCH SHEKO ZONE", 589, "MIZANTEFERI CITY ADMINISTRATION"], + [70, "Ethiopia", 15, "SOUTH WEST", 46, "BENCH SHEKO ZONE", 711, "SEMEN BENCH WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 46, "BENCH SHEKO ZONE", 712, "DEBUB BENCH WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 46, "BENCH SHEKO ZONE", 820, "NORTH BENCH"], + [70, "Ethiopia", 15, "SOUTH WEST", 46, "BENCH SHEKO ZONE", 821, "SOUTH BENCH"], + [70, "Ethiopia", 15, "SOUTH WEST", 46, "BENCH SHEKO ZONE", 1209, "GIDI BENCH WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 67, "SHEKA", 325, "MASHA"], + [70, "Ethiopia", 15, "SOUTH WEST", 67, "SHEKA", 326, "YEKI"], + [70, "Ethiopia", 15, "SOUTH WEST", 67, "SHEKA", 327, "ANDERACHA"], + [70, "Ethiopia", 15, "SOUTH WEST", 67, "SHEKA", 590, "TEPPI CITY ADMINISTRATION"], + [70, "Ethiopia", 15, "SOUTH WEST", 67, "SHEKA", 729, "MASHA CITY ADMINISTRATION"], + [70, "Ethiopia", 15, "SOUTH WEST", 67, "SHEKA", 873, "MASHA TOWN ADM"], + [70, "Ethiopia", 15, "SOUTH WEST", 102, "MIRAB OMO ZONE", 318, "MENIT GOLDIA WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 102, "MIRAB OMO ZONE", 320, "SURMA WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 102, "MIRAB OMO ZONE", 322, "MENIT SHASHA WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 102, "MIRAB OMO ZONE", 323, "BERO WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 102, "MIRAB OMO ZONE", 324, "MAJI WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 102, "MIRAB OMO ZONE", 1280, "BACHUMA CITY ADMINSTRATION "], + [70, "Ethiopia", 15, "SOUTH WEST", 102, "MIRAB OMO ZONE", 1281, "JEMU CITY ADMINSTRATION "], + [70, "Ethiopia", 15, "SOUTH WEST", 102, "MIRAB OMO ZONE", 1282, "MAJI TUM CITY ADMINSTRATION "], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 283, "GORO"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 284, "CHEHA"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 285, "ENEMOR"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 286, "EZA"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 287, "GUMER"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 288, "KOKIR"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 289, "MESKAN"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 290, "ABASHEGE"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 291, "MAREKO"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 292, "SODO"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 293, "MIHUR AKLIL"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 294, "ENDEGAN"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 583, "WOLKITE CITY ADMINISTRATION"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 584, "BUTAJERA CITY ADMINISTRATION"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 625, "KEBENA"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 723, "GETA"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 801, "GETO"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 1215, "ENDIBIR TOWN ADMINSTRATION"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 1216, "BUIE TOWN ADMINSTRATION"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 1217, "DEBUB SODO WOREDA"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 1272, "ENSENO TOWN ADMINSTRATION"], +]; diff --git a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts index 2f1991df2..8990e1b43 100644 --- a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts @@ -67,6 +67,7 @@ const TRIGGER_ROUTE_LABELS: Partial> = { DEMURRAGE: 'Demurrage / wagon detention', PIL_EXTRA_FEE: 'PIL shipping line extra fee', CUSTOMS_CLEARANCE: 'Customs clearance service', + ETHIOPIAN_CUSTOMS_CLEARANCE: 'Ethiopian customs clearance service', FUEL: 'Fuel surcharge', }; diff --git a/apps/edr-freight-api/src/migrations/3620000000000-AdditionalCharge.ts b/apps/edr-freight-api/src/migrations/3620000000000-AdditionalCharge.ts new file mode 100644 index 000000000..2525c1b3d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3620000000000-AdditionalCharge.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** Ad-hoc customer charges finance raises against a booking — Additional Payments tab. */ +export class AdditionalCharge3620000000000 implements MigrationInterface { + name = 'AdditionalCharge3620000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "freight"."additional_charge" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz, + "booking_id" uuid NOT NULL, + "reason" text NOT NULL, + "status" character varying(20) NOT NULL DEFAULT 'DRAFT', + "amount" numeric(14,2) NOT NULL, + "currency" character varying(8) NOT NULL, + "file_record_id" uuid, + "invoice_id" uuid, + "payment_reference" character varying(64), + "created_by_staff_id" uuid, + "sent_by_staff_id" uuid, + "sent_at" timestamptz, + "paid_at" timestamptz, + "cancelled_by_staff_id" uuid, + "cancelled_at" timestamptz, + "cancel_reason" text, + CONSTRAINT "pk_additional_charge" PRIMARY KEY ("id"), + CONSTRAINT "fk_additional_charge_booking" FOREIGN KEY ("booking_id") + REFERENCES "freight"."bookings"("id") ON DELETE CASCADE + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "idx_additional_charge_booking" + ON "freight"."additional_charge" ("booking_id") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "freight"."additional_charge"`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3620000000000-SchedulePlannedWagonYards.ts b/apps/edr-freight-api/src/migrations/3620000000000-SchedulePlannedWagonYards.ts new file mode 100644 index 000000000..9313c22a5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3620000000000-SchedulePlannedWagonYards.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-schedule wagon yard plan — where THIS departure expects each consist + * wagon to board, independent of where the wagon physically stands today. + * + * `wagons.current_yard_id` is one physical fact shared by every schedule of a + * built train, so a train standing in Mojo could not be sold from Dire for a + * departure next week. The plan is a sparse jsonb map `{ wagonId: yardId }` + * on the schedule: a wagon missing from the map boards from its physical yard. + * Booking capacity, fleet availability and wagon pinning all read the plan; + * dispatch refuses to leave until the plan and the physical yards agree. + */ +export class SchedulePlannedWagonYards3620000000000 implements MigrationInterface { + name = 'SchedulePlannedWagonYards3620000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS planned_wagon_yards jsonb + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS planned_wagon_yards + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3630000000000-ClearanceChargeCustomerDecision.ts b/apps/edr-freight-api/src/migrations/3630000000000-ClearanceChargeCustomerDecision.ts new file mode 100644 index 000000000..a9cb259cd --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3630000000000-ClearanceChargeCustomerDecision.ts @@ -0,0 +1,49 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * The customer now approves a clearance charge before it becomes an invoice: + * GL describes the price, SENDs it, the customer ACCEPTs (invoice issued, charge + * locked) or REJECTs with a note (GL revises and re-sends). Charges that were + * already sent as invoices under the old flow are carried over as ACCEPTED so + * their invoices stay payable. + */ +export class ClearanceChargeCustomerDecision3630000000000 + implements MigrationInterface +{ + name = 'ClearanceChargeCustomerDecision3630000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "freight"."booking_clearance_charge" + ADD COLUMN IF NOT EXISTS "description" text, + ADD COLUMN IF NOT EXISTS "customer_note" text, + ADD COLUMN IF NOT EXISTS "customer_decided_at" timestamptz, + ADD COLUMN IF NOT EXISTS "customer_decided_by" uuid + `); + await queryRunner.query(` + UPDATE "freight"."booking_clearance_charge" + SET "status" = 'ACCEPTED' + WHERE "status" = 'SENT' AND "invoice_id" IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE "freight"."booking_clearance_charge" + SET "status" = 'SENT' + WHERE "status" = 'ACCEPTED' + `); + await queryRunner.query(` + UPDATE "freight"."booking_clearance_charge" + SET "status" = 'BILLED' + WHERE "status" = 'REJECTED' + `); + await queryRunner.query(` + ALTER TABLE "freight"."booking_clearance_charge" + DROP COLUMN IF EXISTS "description", + DROP COLUMN IF EXISTS "customer_note", + DROP COLUMN IF EXISTS "customer_decided_at", + DROP COLUMN IF EXISTS "customer_decided_by" + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3640000000000-EthiopianCustomsClearance.ts b/apps/edr-freight-api/src/migrations/3640000000000-EthiopianCustomsClearance.ts new file mode 100644 index 000000000..50ceda3ca --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3640000000000-EthiopianCustomsClearance.ts @@ -0,0 +1,69 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Ethiopian-side-only customs clearance: + * + * - service_types.includes_ethiopian_customs_only marks a customs service that + * EDR clears on the Ethiopian side only. Same clearance flow; only the fee + * differs — pricing looks up the ETHIOPIAN_CUSTOMS_CLEARANCE rate instead of + * CUSTOMS_CLEARANCE. + * - rates.trigger widens to 30 chars to fit the new trigger value. + * - CK_rates_yard_scope gains ETHIOPIAN_CUSTOMS_CLEARANCE in its yard-carrying + * branch: it is priced per origin → destination leg like customs clearance. + */ +export class EthiopianCustomsClearance3640000000000 implements MigrationInterface { + name = 'EthiopianCustomsClearance3640000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.service_types + ADD COLUMN IF NOT EXISTS includes_ethiopian_customs_only boolean NOT NULL DEFAULT false + `); + + await queryRunner.query( + `ALTER TABLE freight.rates ALTER COLUMN trigger TYPE varchar(30)`, + ); + + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR + CASE + WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')) + OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL') + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR + CASE + WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')) + OR trigger IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL') + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ) + `); + // Rows on the new trigger would not fit varchar(20) — drop them first. + await queryRunner.query( + `DELETE FROM freight.rates WHERE trigger = 'ETHIOPIAN_CUSTOMS_CLEARANCE'`, + ); + await queryRunner.query( + `ALTER TABLE freight.rates ALTER COLUMN trigger TYPE varchar(20)`, + ); + await queryRunner.query( + `ALTER TABLE freight.service_types DROP COLUMN IF EXISTS includes_ethiopian_customs_only`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3650000000000-AdditionalChargeDueAt.ts b/apps/edr-freight-api/src/migrations/3650000000000-AdditionalChargeDueAt.ts new file mode 100644 index 000000000..8dcda8181 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3650000000000-AdditionalChargeDueAt.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** Optional payment due date finance can set on an additional charge. */ +export class AdditionalChargeDueAt3650000000000 implements MigrationInterface { + name = 'AdditionalChargeDueAt3650000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "freight"."additional_charge" + ADD COLUMN IF NOT EXISTS "due_at" timestamptz + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "freight"."additional_charge" DROP COLUMN IF EXISTS "due_at" + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3650000000000-SchedulePlannedWagonCutYards.ts b/apps/edr-freight-api/src/migrations/3650000000000-SchedulePlannedWagonCutYards.ts new file mode 100644 index 000000000..d061cd9e0 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3650000000000-SchedulePlannedWagonCutYards.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-schedule wagon CUT plan — the mid-route stop where THIS departure + * detaches each consist wagon and leaves it behind (10 wagons cut at Mojo, + * the rest ride to Djibouti). + * + * Sparse jsonb map `{ wagonId: yardId }` on the schedule: a wagon missing + * from the map rides to the schedule destination — exactly today's behavior, + * so no backfill. The cut is a cap, not a promise: cargo may still alight + * earlier, but never past the cut. Booking capacity debits every edge at or + * after the cut; checkpoint logging settles the wagon there physically. + */ +export class SchedulePlannedWagonCutYards3650000000000 implements MigrationInterface { + name = 'SchedulePlannedWagonCutYards3650000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS planned_wagon_cut_yards jsonb + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS planned_wagon_cut_yards + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3660000000000-SchedulePlannedWagonCouples.ts b/apps/edr-freight-api/src/migrations/3660000000000-SchedulePlannedWagonCouples.ts new file mode 100644 index 000000000..ff87201f9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3660000000000-SchedulePlannedWagonCouples.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-schedule consist-change plan, executed automatically as the trip + * proceeds (dispatch / checkpoint logs): + * + * - `planned_wagon_couples` `{ wagonId: pickupYardId }` — LOOSE wagons this + * departure couples onto the train at a route stop. They join the built + * train permanently when the train reaches that stop. + * - `planned_wagon_real_cuts` `[wagonId, ...]` — cut wagons (see + * planned_wagon_cut_yards) flagged as REAL cuts: the built train + * permanently loses the wagon at its cut yard, instead of the default + * soft cut where it stays in the build and only sits out this trip. + */ +export class SchedulePlannedWagonCouples3660000000000 implements MigrationInterface { + name = 'SchedulePlannedWagonCouples3660000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS planned_wagon_couples jsonb + `); + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS planned_wagon_real_cuts jsonb + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS planned_wagon_couples + `); + await queryRunner.query(` + ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS planned_wagon_real_cuts + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3670000000000-AdjustmentLogNullableSchedule.ts b/apps/edr-freight-api/src/migrations/3670000000000-AdjustmentLogNullableSchedule.ts new file mode 100644 index 000000000..6bd2bdd62 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3670000000000-AdjustmentLogNullableSchedule.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * A consist adjustment made from the TRAIN BUILDER on a train with no live + * schedule still belongs in the wagon adjustment history — it just has no + * schedule to point at. Relax the NOT NULL so builder detaches/attaches can + * be recorded; every existing reader filters BY train_schedule_id or + * train_id, so nullable rows are invisible to them. + */ +export class AdjustmentLogNullableSchedule3670000000000 implements MigrationInterface { + name = 'AdjustmentLogNullableSchedule3670000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.schedule_wagon_adjustment_logs + ALTER COLUMN train_schedule_id DROP NOT NULL + `); + } + + public async down(_queryRunner: QueryRunner): Promise { + // No-op: restoring NOT NULL would fail on any builder-origin rows written + // while this migration was live, re-introducing the outage it fixed. + } +} diff --git a/apps/edr-freight-api/src/migrations/3680000000000-WagonAllocationSlotIndex.ts b/apps/edr-freight-api/src/migrations/3680000000000-WagonAllocationSlotIndex.ts new file mode 100644 index 000000000..c5c855fb5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3680000000000-WagonAllocationSlotIndex.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Every slot → allocations lookup (allocator, journey load/unload, settle, + * per-leg weight guard) filters wagon_booking_allocations by + * train_set_wagon_id, which had no index — only booking_id and the pkey. + * Sequential scans grow with every allocation ever written. + */ +export class WagonAllocationSlotIndex3680000000000 implements MigrationInterface { + name = 'WagonAllocationSlotIndex3680000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagon_booking_allocations_slot + ON freight.wagon_booking_allocations (train_set_wagon_id) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS freight.idx_wagon_booking_allocations_slot + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3690000000000-CheckpointHandlingTimes.ts b/apps/edr-freight-api/src/migrations/3690000000000-CheckpointHandlingTimes.ts new file mode 100644 index 000000000..f4cae1161 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3690000000000-CheckpointHandlingTimes.ts @@ -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 { + 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 { + 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; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3700000000000-HandlingStandards.ts b/apps/edr-freight-api/src/migrations/3700000000000-HandlingStandards.ts new file mode 100644 index 000000000..94a9531a5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3700000000000-HandlingStandards.ts @@ -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 { + 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 { + await queryRunner.query(` + ALTER TABLE freight.operations_standards + DROP COLUMN IF EXISTS handling_standard_hours_container, + DROP COLUMN IF EXISTS handling_standard_hours_bulk; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index c5cc9a81e..a3ed3e417 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -1,5 +1,6 @@ import { Freight } from "@edr/types"; +import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "../bookings/entities/booking-wagon-cancellation.entity"; import { BillingService } from "./billing.service"; /** @@ -1033,3 +1034,73 @@ describe("BillingService.document", () => { expect(render).not.toHaveBeenCalled(); }); }); + +/** + * The pay-window guard belongs to the freight invoice. A wagon-cancellation fee + * rides source=booking but is raised on an already-PAID booking, so it inherits + * a deadline that has long passed — guarding it would make the fee permanently + * unsettleable. + */ +describe("BillingService.confirmOfflinePayment pay-window guard", () => { + const PAST = new Date(Date.now() - 86_400_000); + + function makeService(invoiceType: string) { + const invoice = { + id: "inv-1", + source: Freight.InvoiceSource.Booking, + sourceId: "booking-1", + type: invoiceType, + currency: "ETB", + status: Freight.InvoiceStatus.Issued, + balanceAmount: 500, + }; + const recordPayment = jest.fn().mockResolvedValue(invoice); + const dataSource = { + getRepository: () => ({ + findOne: async () => ({ id: "booking-1", paymentDeadline: PAST }), + }), + }; + const service = new BillingService( + dataSource as never, + { findById: async () => invoice } as never, + {} as never, + makeEvents() as never, + {} as never, + {} as never, + {} as never, + { upload: async () => ({ id: "file-1", name: "slip.pdf" }) } as never, + { get: () => undefined } as never, + { isEnabled: async () => true } as never, + ); + (service as unknown as { recordPayment: unknown }).recordPayment = + recordPayment; + return { service, recordPayment }; + } + + const slip = { originalname: "slip.pdf" } as never; + + it("refuses a freight invoice once the pay window has closed", async () => { + const { service } = makeService("PREPAID"); + await expect( + service.confirmOfflinePayment("inv-1", slip, {}), + ).rejects.toThrow(/payment window has closed/i); + }); + + it("settles a wagon-cancellation fee despite the closed window", async () => { + const { service, recordPayment } = makeService( + WAGON_CANCEL_FEE_INVOICE_TYPE, + ); + await service.confirmOfflinePayment("inv-1", slip, {}); + expect(recordPayment).toHaveBeenCalledWith( + "inv-1", + expect.objectContaining({ amount: 500, method: "BANK_TRANSFER" }), + ); + }); + + it("still requires the bank slip for a cancellation fee", async () => { + const { service } = makeService(WAGON_CANCEL_FEE_INVOICE_TYPE); + await expect( + service.confirmOfflinePayment("inv-1", undefined, {}), + ).rejects.toThrow(/slip file is required/i); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index ac1c2ef24..175e3e00c 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -13,6 +13,8 @@ import { logCtx } from "@edr/api-common"; import { DataSource, EntityManager, In, SelectQueryBuilder } from "typeorm"; import { Booking } from "../bookings/entities/booking.entity"; +import { AdditionalCharge } from "../bookings/entities/additional-charge.entity"; +import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "../bookings/entities/booking-wagon-cancellation.entity"; // Entity-only import (no module edge): portal reads resolve shipping-line // payers straight off the table. import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity"; @@ -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, - 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> { 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 { 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. diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts new file mode 100644 index 000000000..55e6b19d2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts @@ -0,0 +1,53 @@ +import { plainToInstance } from "class-transformer"; +import { validateSync } from "class-validator"; + +import { FilterInvoiceDto } from "./filter-invoice.dto"; + +/** + * The list endpoint runs under `forbidNonWhitelisted`, so every param the + * backoffice filter bar sends has to survive transform + validation here or + * the whole request 400s. The CSV filters are the fragile part: they arrive as + * one string and must come out as a validated array. + */ +const parse = (query: Record) => { + const dto = plainToInstance(FilterInvoiceDto, query); + return { dto, errors: validateSync(dto).map((e) => e.property) }; +}; + +describe("FilterInvoiceDto", () => { + it("accepts the full filter-bar query and splits the CSV filters", () => { + const { dto, errors } = parse({ + page: "2", + pageSize: "10", + search: "INV-2026", + statuses: "PENDING,OVERDUE", + sources: "booking,warehouse", + eimsStatuses: "NOT_SUBMITTED", + currency: "etb", + issuedFrom: "2026-08-01T00:00:00.000Z", + issuedTo: "2026-08-20T20:59:59.999Z", + dueFrom: "2026-08-01T00:00:00.000Z", + dueTo: "2026-09-01T20:59:59.999Z", + minAmount: "100", + maxAmount: "5000", + hasBalance: "true", + overdue: "false", + sortBy: "balanceAmount", + sortOrder: "asc", + }); + + expect(errors).toEqual([]); + expect(dto.statuses).toEqual(["PENDING", "OVERDUE"]); + expect(dto.sources).toEqual(["booking", "warehouse"]); + expect(dto.currency).toBe("ETB"); + expect(dto.minAmount).toBe(100); + expect(dto.hasBalance).toBe(true); + expect(dto.overdue).toBe(false); + expect(dto.sortOrder).toBe("ASC"); + }); + + it("rejects a value outside the enum and an unsortable column", () => { + expect(parse({ statuses: "PENDING,NOT_A_STATUS" }).errors).toEqual(["statuses"]); + expect(parse({ sortBy: "eimsIrn" }).errors).toEqual(["sortBy"]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts index 91327946c..a98ad06c1 100644 --- a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts @@ -2,14 +2,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 = { + 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"; } diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts index 75d39a500..bcc7fcfc3 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts @@ -60,11 +60,7 @@ const context = (over: Partial = {}): EimsMapperContext => ({ unitDefault: "PCS", incomeWithholdValue: 0, transactionWithholdValue: 0, - buyerCountryCode: "231", // test-only, not a confirmed real MoR code - buyerCountryCodes: {}, - buyerRegionCodes: { "Addis Ababa": "13" }, - buyerWeredaCodes: {}, - buyerCityCodes: {}, + buyerGeo: { Country: "70", Region: "6", City: "31", Wereda: "190" }, ...over, }); @@ -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", () => { diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts index c72e61c7a..0b9eb9e29 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts @@ -15,6 +15,7 @@ * authoritative: they are passed through or overridable rather than validated against a fixed set. */ +import { MorGeoCodes } from "../../config/mor-location.resolver"; import { round2 } from "./invoice-settlement.util"; /** Only proven-required constant: the 400 SCHEMA ERROR sample rejects a payload without it. */ @@ -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; - /** - * Region name → MoR numeric code, for buyers whose stored region is free text. + * The buyer's MoR location codes — `Country`/`Region`/`City`/`Wereda`, already resolved from the + * Ministry's location master by `resolveMorGeo`. * - * `companies.region` holds names ("Addis Ababa") while MoR validates `BuyerDetails.Region` - * against `^[0-9]{1,3}$`. A stored value that is already a code passes through; anything else - * must be in this map or the mapping **fails locally** — sending a guessed region code onto a - * tax document is worse than refusing to file. + * Resolved by the caller, not here, and deliberately so: geographic resolution can fail (unknown + * or ambiguous address) and that failure must happen **before** an EIMS counter is reserved, so a + * bad company address never burns a sequence number. See `mor-location.resolver.ts` for why the + * lookup has to be hierarchical, and `EimsInvoiceRegistrationService` for where it runs. */ - buyerRegionCodes: Record; - /** - * Wereda name → MoR code, same shape as `buyerRegionCodes`. `companies.woreda` holds names - * ("Yeka") or codes inconsistently; unlike Region, MoR has never named a Wereda regex in an - * error, so this is precautionary rather than confirmed — but the fix is identical either way: - * fail locally on an unmapped name rather than file a guess. - */ - buyerWeredaCodes: Record; - /** - * Buyer *zone* name → MoR City code. `Company` has no dedicated city column; Zone is the - * closest match in EDR's own data. Unlike Region/Wereda, City is optional — MoR has already - * accepted a live filing with it null — so an unmapped zone resolves to null, it does not fail - * the mapping. - */ - buyerCityCodes: Record; + buyerGeo: MorGeoCodes; buyerIdType?: string | null; buyerIdNumber?: string | null; /** Required when the invoice currency is not ETB. */ @@ -273,14 +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, - envVar: string, - invoiceNumber: string, - opts: { required?: boolean } = {}, -): string | null { - const raw = (value ?? "").trim(); - if (LOCATION_CODE.test(raw)) return raw; - - const key = raw.toLowerCase().replace(/\s+/g, " "); - const mapped = Object.entries(codes).find( - ([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key, - )?.[1]; - if (mapped && LOCATION_CODE.test(mapped)) return mapped; - - if (opts.required === false) return null; - - throw new Error( - `EIMS mapping: invoice ${invoiceNumber} has buyer ${field} ${raw ? `"${raw}"` : "(unset)"}, ` + - `which is not a MoR ${field} code and has no mapping. Add it to ${envVar}.`, - ); -} - -/** - * A buyer's `Country` as a MoR code: looked up by name in `codes` first; when unmapped, applies - * `domesticFallback` only if the stored country is empty or "Ethiopia" (the DB column's default). - * A genuinely foreign, unmapped country throws rather than silently filing as Ethiopia — same - * "fail locally, don't guess" rule as `resolveLocationCode`, but never digit-validated: MoR's - * Country code format is unconfirmed, unlike Region/Wereda's proven `^[0-9]{1,3}$`. - */ -function resolveCountryCode( - country: string | null | undefined, - codes: Record, - domesticFallback: string | null, - invoiceNumber: string, -): string | null { - const raw = (country ?? "").trim(); - const key = raw.toLowerCase().replace(/\s+/g, " "); - const mapped = Object.entries(codes).find( - ([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key, - )?.[1]; - if (mapped) return mapped; - - if ((!raw || key === "ethiopia") && domesticFallback) return domesticFallback; - - throw new Error( - `EIMS mapping: invoice ${invoiceNumber} has buyer Country "${raw || "(unset)"}", which has no ` + - "MoR country code mapping. Add it to EIMS_BUYER_COUNTRY_CODES.", - ); -} - -/** - * Same name-or-code resolution as `resolveLocationCode`, for a caller with no invoice to attach an - * error to and that must never throw — currently only `EimsSellerCacheService`, resolving - * e-Trade's region/zone/woreda *names* for EDR's own seller identity. Pass-through numeric code, - * name lookup, `undefined` on no match — the caller falls back to static config either way. - */ -export function resolveOptionalCode( - value: string | null | undefined, - codes: Record, -): string | undefined { - const raw = (value ?? "").trim(); - if (LOCATION_CODE.test(raw)) return raw; - const key = raw.toLowerCase().replace(/\s+/g, " "); - const mapped = Object.entries(codes).find( - ([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key, - )?.[1]; - return mapped && LOCATION_CODE.test(mapped) ? mapped : undefined; -} - export function toEimsInvoice( invoice: EimsMapperInvoice, seller: EimsSellerDetails, @@ -511,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, diff --git a/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts index ab1e27b1a..172e74c17 100644 --- a/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts +++ b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/bookings/ad-hoc-label.spec.ts b/apps/edr-freight-api/src/modules/bookings/ad-hoc-label.spec.ts new file mode 100644 index 000000000..dd67b7acc --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/ad-hoc-label.spec.ts @@ -0,0 +1,33 @@ +import { adHocLabel } from './clearance.util'; + +/** + * The customer's typed document name travels to the API inside the multipart + * field code (`custom__`) — the only channel a part has — and comes + * back out here for GL's review grid. Mirror of `adHocSlug` in the portal's + * useClearanceFlow. + */ +const adHocSlug = (name: string) => + name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60); + +const roundTrip = (typed: string) => adHocLabel(`custom_${adHocSlug(typed)}_17877000000000`); + +describe('adHocLabel', () => { + it('recovers the name the customer typed', () => { + expect(roundTrip('Special permit')).toBe('Special permit'); + expect(roundTrip('Fumigation Certificate')).toBe('Fumigation certificate'); + expect(roundTrip('bank slip #2')).toBe('Bank slip 2'); + }); + + it('returns null when there is no name to show, so callers use the filename', () => { + expect(roundTrip('')).toBeNull(); + // Legacy uploads keyed `custom__` carry no name — without the + // digits guard this would surface "1755780000000" as the document label. + expect(adHocLabel('custom_1755780000000_0')).toBeNull(); + expect(adHocLabel('commercial_invoice')).toBeNull(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/additional-charge.repository.ts b/apps/edr-freight-api/src/modules/bookings/additional-charge.repository.ts new file mode 100644 index 000000000..9a7e652b0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/additional-charge.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { AdditionalCharge } from './entities/additional-charge.entity'; + +@Injectable() +export class AdditionalChargeRepository extends BaseRepository { + constructor(@InjectRepository(AdditionalCharge) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts b/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts new file mode 100644 index 000000000..012d5f8db --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts @@ -0,0 +1,314 @@ +import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { DataSource, EntityManager } from 'typeorm'; +import { ExchangeService } from '@edr/api-common'; +import { Freight, NotificationAudience, NotificationType } from '@edr/types'; + +import { BillingService, InvoiceEventPayload } from '../billing/billing.service'; +import { Invoice } from '../billing/entities/invoice.entity'; +import { FilesService } from '../files/files.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { sendCompanyChannels } from '../notifications/notify-company.util'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { BookingsService } from './bookings.service'; +import { BookingsRepository } from './bookings.repository'; +import { AdditionalChargeRepository } from './additional-charge.repository'; +import { AdditionalCharge } from './entities/additional-charge.entity'; +import { CreateAdditionalChargeDto } from './dto/additional-charge.dto'; + +const FILE_RESOURCE = 'additional_charges'; + +/** + * Ad-hoc extra charges finance raises against a booking, independent of + * `BookingClearanceCharge` (which is capped at one PORT_CHARGES/MISCELLANEOUS + * row per booking). Any number per booking, free-text reason. DRAFT until + * sent; sending issues the payable invoice and notifies the customer + * (in-app + SMS + email). Settles via `additional_charge.invoice.paid`, + * same event-driven pattern as every other invoice source. + */ +@Injectable() +export class AdditionalChargeService { + private readonly logger = new Logger(AdditionalChargeService.name); + + constructor( + private readonly dataSource: DataSource, + private readonly repository: AdditionalChargeRepository, + private readonly bookingsRepository: BookingsRepository, + private readonly filesService: FilesService, + private readonly exchangeService: ExchangeService, + private readonly billing: BillingService, + private readonly bookingsService: BookingsService, + private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, + ) {} + + private async findOwned(bookingId: string, chargeId: string): Promise { + const charge = await this.repository.findById(chargeId); + if (!charge || charge.bookingId !== bookingId) { + throw new NotFoundException('Additional charge not found'); + } + return charge; + } + + async list(bookingId: string): Promise { + const rows = await this.repository.findAll({ + where: { bookingId }, + order: { createdAt: 'DESC' }, + }); + return this.toDtoList(rows); + } + + async create( + bookingId: string, + dto: CreateAdditionalChargeDto, + staffId: string, + file?: Express.Multer.File, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + const shouldSend = dto.action === 'send'; + + const chargeId = await this.dataSource.transaction(async (manager) => { + const repo = manager.getRepository(AdditionalCharge); + let saved = await repo.save( + repo.create({ + bookingId, + reason: dto.reason.trim(), + amount: dto.amount.toFixed(2), + currency: dto.currency.trim().toUpperCase(), + dueAt: dto.dueDate ? new Date(dto.dueDate) : null, + status: 'DRAFT', + createdByStaffId: staffId, + }), + ); + + if (file) { + const record = await this.filesService.upload({ + resourceId: saved.id, + resource: FILE_RESOURCE, + code: FILE_RESOURCE, + file, + uploadedByUserId: staffId, + }); + await repo.update(saved.id, { fileRecordId: record.id }); + } + + if (shouldSend) { + saved = await this.issueInvoice(manager, saved.id, booking, staffId); + } + return saved.id; + }); + + if (shouldSend) await this.notifyCustomerSent(chargeId); + return this.list(bookingId); + } + + async send(bookingId: string, chargeId: string, staffId: string): Promise { + const charge = await this.findOwned(bookingId, chargeId); + if (charge.status !== 'DRAFT') { + throw new ConflictException('Only a draft charge can be sent.'); + } + const booking = await this.bookingsService.findById(bookingId); + + await this.dataSource.transaction((manager) => + this.issueInvoice(manager, charge.id, booking, staffId), + ); + await this.notifyCustomerSent(charge.id); + return this.list(bookingId); + } + + /** Issues the invoice and flips DRAFT → SENT. Notification happens after commit — never inside the transaction. */ + private async issueInvoice( + manager: EntityManager, + chargeId: string, + booking: { id: string; companyId?: string | null; companyProfileId?: string | null; reference?: string | null }, + staffId: string, + ): Promise { + const repo = manager.getRepository(AdditionalCharge); + const charge = await repo.findOneByOrFail({ id: chargeId }); + + const invoice = await this.billing.generateInvoice( + { + source: Freight.InvoiceSource.AdditionalCharge, + sourceId: charge.id, + type: 'ADDITIONAL_CHARGE', + companyId: booking.companyId, + companyProfileId: booking.companyProfileId, + currency: charge.currency, + // Unset falls through to BillingService's own DEFAULT_DUE_DAYS (14). + dueAt: charge.dueAt ?? undefined, + lines: [ + { + chargeType: 'ADDITIONAL_CHARGE', + description: `${charge.reason} — ${booking.reference ?? booking.id}`, + amount: Number(charge.amount), + }, + ], + }, + manager, + ); + + await repo.update(charge.id, { + status: 'SENT', + invoiceId: invoice.id, + sentByStaffId: staffId, + sentAt: new Date(), + }); + this.logger.log( + `Additional charge ${charge.id} on booking ${booking.id} sent as invoice ${invoice.invoiceNumber}`, + ); + return repo.findOneByOrFail({ id: charge.id }); + } + + private async notifyCustomerSent(chargeId: string): Promise { + try { + const charge = await this.repository.findById(chargeId); + if (!charge) return; + const booking = await this.bookingsService.findById(charge.bookingId); + if (!booking.companyId) return; + const body = `A new charge of ${charge.amount} ${charge.currency} has been added to booking ${booking.reference ?? charge.bookingId}: ${charge.reason}. Pay via the portal.`; + await this.inbox.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.INVOICE_ISSUED, + title: 'New charge on your booking', + body, + link: `/bookings/${charge.bookingId}`, + data: { + bookingId: charge.bookingId, + chargeId: charge.id, + amount: Number(charge.amount), + currency: charge.currency, + }, + }); + await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body); + } catch (err) { + this.logger.warn(`Additional charge sent-notify failed for ${chargeId}: ${(err as Error).message}`); + } + } + + async cancel( + bookingId: string, + chargeId: string, + staffId: string, + reason?: string, + ): Promise { + const charge = await this.findOwned(bookingId, chargeId); + if (charge.status !== 'DRAFT' && charge.status !== 'SENT') { + throw new ConflictException('Only a draft or unpaid charge can be cancelled.'); + } + if (charge.status === 'SENT' && charge.invoiceId) { + await this.billing.cancelInvoice(charge.invoiceId); + } + await this.repository.update(charge.id, { + status: 'CANCELLED', + cancelledByStaffId: staffId, + cancelledAt: new Date(), + cancelReason: reason ?? null, + }); + return this.list(bookingId); + } + + /** Gateway and manual settlements both land here (`${source}.invoice.paid`). */ + @OnEvent('additional_charge.invoice.paid') + async onChargeInvoicePaid(payload: InvoiceEventPayload): Promise { + const charge = await this.repository.findById(payload.sourceId); + if (!charge || charge.status === 'PAID') return; + await this.repository.update(charge.id, { status: 'PAID', paidAt: new Date() }); + + try { + const booking = await this.bookingsService.findById(charge.bookingId); + if (!booking.companyId) return; + const body = `Payment received for ${charge.amount} ${charge.currency} on booking ${booking.reference ?? charge.bookingId}: ${charge.reason}.`; + await this.inbox.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.PAYMENT_RECEIVED, + title: 'Charge payment received', + body, + link: `/bookings/${charge.bookingId}`, + data: { bookingId: charge.bookingId, chargeId: charge.id }, + }); + await this.inbox.notify({ + recipients: { permissionKeys: [FREIGHT_PERMS.additionalCharges.getNotification] }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.PAYMENT_RECEIVED, + title: 'Additional charge paid', + body, + link: `/bookings/${charge.bookingId}`, + data: { bookingId: charge.bookingId, chargeId: charge.id }, + }); + } catch (err) { + this.logger.warn(`Additional charge paid-notify failed for ${charge.id}: ${(err as Error).message}`); + } + } + + private async toDtoList(rows: AdditionalCharge[]): Promise { + if (!rows.length) return []; + + const filesByCharge = await this.filesService.findByResourceIdsGrouped( + rows.map((r) => r.id), + FILE_RESOURCE, + ); + const names = await this.bookingsRepository.resolveStaffNames( + rows.flatMap((r) => [r.createdByStaffId, r.sentByStaffId]), + ); + + const invoiceIds = rows.map((r) => r.invoiceId).filter((id): id is string => Boolean(id)); + const invoices = invoiceIds.length + ? await this.dataSource.getRepository(Invoice).find({ where: invoiceIds.map((id) => ({ id })) }) + : []; + const invoiceById = new Map(invoices.map((i) => [i.id, i])); + const converted = await Promise.all(rows.map((r) => this.convertAmount(r))); + const convertedById = new Map(rows.map((r, i) => [r.id, converted[i]])); + + return rows.map((r) => { + const file = filesByCharge.get(r.id)?.[0]; + const fx = convertedById.get(r.id) ?? null; + return { + id: r.id, + bookingId: r.bookingId, + reason: r.reason, + status: r.status, + amount: Number(r.amount), + currency: r.currency, + convertedAmount: fx?.amount ?? null, + convertedCurrency: fx?.currency ?? null, + dueAt: r.dueAt?.toISOString() ?? null, + file: file ? { id: file.id, name: file.name, url: file.url } : null, + invoiceId: r.invoiceId ?? null, + invoiceNumber: r.invoiceId ? (invoiceById.get(r.invoiceId)?.invoiceNumber ?? null) : null, + paymentReference: r.paymentReference ?? null, + createdByName: r.createdByStaffId ? (names.get(r.createdByStaffId) ?? null) : null, + createdAt: r.createdAt.toISOString(), + sentByName: r.sentByStaffId ? (names.get(r.sentByStaffId) ?? null) : null, + sentAt: r.sentAt?.toISOString() ?? null, + paidAt: r.paidAt?.toISOString() ?? null, + cancelledAt: r.cancelledAt?.toISOString() ?? null, + cancelReason: r.cancelReason ?? null, + }; + }); + } + + /** + * Amount converted to the other of ETB/USD, via the existing shared + * `ExchangeService` (CBE rate, falls back to the stored `exchange_settings` + * rate) — same mechanism `booking-wagon-cancellation.service.ts` and + * warehouse fee pricing already use. Null on anything but ETB/USD, or if + * the rate feed is down — this is a display convenience, not the payable + * amount, so a failure here must never break the charge list. + */ + private async convertAmount( + charge: AdditionalCharge, + ): Promise<{ amount: number; currency: string } | null> { + if (charge.currency !== 'ETB' && charge.currency !== 'USD') return null; + const target = charge.currency === 'ETB' ? 'USD' : 'ETB'; + try { + const amount = await this.exchangeService.convert(Number(charge.amount), charge.currency, target); + return { amount: Math.round(amount * 100) / 100, currency: target }; + } catch (err) { + this.logger.warn(`Rate conversion failed for charge ${charge.id}: ${(err as Error).message}`); + return null; + } + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts index 3be119470..a1bceb844 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts @@ -14,9 +14,11 @@ import { Invoice } from '../billing/entities/invoice.entity'; import { FilesService } from '../files/files.service'; import { BookingsService } from './bookings.service'; import { BookingsRepository } from './bookings.repository'; +import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service'; import { Booking } from './entities/booking.entity'; import { BookingClearanceCharge, + ClearanceChargeStatus, ClearanceChargeType, } from './entities/booking-clearance-charge.entity'; import { ClearanceEventService } from './clearance-event.service'; @@ -32,13 +34,22 @@ const CHARGE_LABEL: Record = { MISCELLANEOUS: 'Miscellaneous charges', }; +/** Statuses the customer sees — drafts (DOC_UPLOADED / BILLED) stay GL-internal. */ +export const CUSTOMER_VISIBLE_CHARGE_STATUSES: ReadonlySet = + new Set(['SENT', 'REJECTED', 'ACCEPTED', 'PAID']); + +/** Once the customer has accepted (invoice issued) or paid, GL cannot touch the charge. */ +export const canStaffEditCharge = (status: ClearanceChargeStatus): boolean => + status !== 'ACCEPTED' && status !== 'PAID'; + /** - * Post-finalization clearance charges billed to the customer. Two levels per - * booking: GL Djibouti uploads the port-charges document; GL Ethiopia bills it - * (amount + currency) and sends the invoice; once that invoice is paid GL - * Ethiopia may create and send the miscellaneous charge. ETB invoices are paid - * through the portal gateway, other currencies through Finance's manual - * settlement worklist — both settle via `clearance_charge.invoice.paid`. + * Post-finalization clearance charges billed to the customer: one port charge + * (document from GL Djibouti, priced by GL Ethiopia) and any number of + * miscellaneous charges. GL prices + describes a charge and SENDs it; the + * customer REJECTs with a note (GL revises, re-sends) or ACCEPTs, which issues + * the payable invoice and locks the charge. ETB invoices are paid through the + * portal gateway, other currencies through Finance's manual settlement + * worklist — both settle via `clearance_charge.invoice.paid`. */ @Injectable() export class BookingClearanceChargeService { @@ -51,6 +62,7 @@ export class BookingClearanceChargeService { private readonly bookingsService: BookingsService, private readonly bookingsRepository: BookingsRepository, private readonly clearanceEvents: ClearanceEventService, + private readonly notifier: BookingLifecycleNotifierService, ) {} private repo() { @@ -104,6 +116,11 @@ export class BookingClearanceChargeService { file: file ? { id: file.id, name: file.name, url: file.url } : null, amount: c.amount != null ? Number(c.amount) : null, currency: c.currency ?? null, + description: c.description ?? null, + customerNote: c.customerNote ?? null, + customerDecidedAt: c.customerDecidedAt + ? c.customerDecidedAt.toISOString() + : null, invoiceId: c.invoiceId ?? null, invoiceNumber: c.invoiceId ? (invoiceById.get(c.invoiceId)?.invoiceNumber ?? null) @@ -121,6 +138,24 @@ export class BookingClearanceChargeService { }); } + /** The customer's view: only charges GL has sent them. */ + async listForCustomer(bookingId: string): Promise { + return (await this.list(bookingId)).filter((c) => + CUSTOMER_VISIBLE_CHARGE_STATUSES.has(c.status), + ); + } + + private async findCharge( + bookingId: string, + chargeId: string, + ): Promise { + const charge = await this.repo().findOne({ + where: { id: chargeId, bookingId }, + }); + if (!charge) throw new NotFoundException('Clearance charge not found'); + return charge; + } + /** GL Djibouti uploads (or replaces, until billed) the port-charges document. */ async uploadPortDocument( bookingId: string, @@ -180,22 +215,21 @@ export class BookingClearanceChargeService { } /** - * GL Ethiopia sets (or, on the customer's request, revises) amount + - * currency. Revising a SENT charge cancels its unpaid invoice; a PAID charge - * is immutable. + * GL Ethiopia sets (or, after a customer rejection, revises) amount + + * currency + description. Allowed until the customer accepts: an ACCEPTED + * charge already carries an invoice and a PAID one is settled. */ async billCharge( bookingId: string, chargeId: string, - input: { amount: number; currency: string }, + input: { amount: number; currency: string; description?: string }, staffId: string, ): Promise { - const charge = await this.repo().findOne({ - where: { id: chargeId, bookingId }, - }); - if (!charge) throw new NotFoundException('Clearance charge not found'); - if (charge.status === 'PAID') { - throw new ConflictException('A paid charge can no longer be changed.'); + const charge = await this.findCharge(bookingId, chargeId); + if (!canStaffEditCharge(charge.status)) { + throw new ConflictException( + 'The customer has accepted this charge — it can no longer be changed.', + ); } if (!(input.amount > 0)) { throw new BadRequestException('Amount must be greater than zero.'); @@ -203,53 +237,117 @@ export class BookingClearanceChargeService { if (!input.currency?.trim()) { throw new BadRequestException('Currency is required.'); } - - if (charge.status === 'SENT' && charge.invoiceId) { - await this.billing.cancelInvoice(charge.invoiceId); + const description = (input.description ?? charge.description ?? '').trim(); + if (charge.type === 'MISCELLANEOUS' && !description) { + throw new BadRequestException('Describe what this charge is for.'); } + const currency = input.currency.trim().toUpperCase(); + const revised = charge.status === 'SENT' || charge.status === 'REJECTED'; + // Back to draft: the customer's previous decision no longer applies. await this.repo().update(charge.id, { amount: input.amount.toFixed(2), - currency: input.currency.trim().toUpperCase(), + currency, + description: description || null, status: 'BILLED', - invoiceId: null, + customerNote: null, + customerDecidedAt: null, + customerDecidedBy: null, billedByStaffId: staffId, billedAt: new Date(), }); await this.clearanceEvents.record({ bookingId, action: 'CHARGE_BILLED', - label: `${charge.status === 'SENT' ? 'Revised' : 'Billed'} ${CHARGE_LABEL[ + label: `${revised ? 'Revised' : 'Billed'} ${CHARGE_LABEL[ charge.type - ].toLowerCase()}: ${input.amount} ${input.currency.trim().toUpperCase()}`, + ].toLowerCase()}: ${input.amount} ${currency}${ + description ? ` — ${description}` : '' + }`, actorId: staffId, metadata: { chargeType: charge.type, amount: input.amount, - currency: input.currency.trim().toUpperCase(), - revised: charge.status === 'SENT', + currency, + description: description || null, + revised, }, }); return this.list(bookingId); } - /** GL Ethiopia issues the payable invoice to the customer. */ + /** + * GL Ethiopia proposes the priced charge to the customer. No invoice yet — + * that is issued when the customer accepts. Re-sending after a rejection + * goes through here too. + */ async sendCharge( bookingId: string, chargeId: string, - staffId?: string, + staffId: string, ): Promise { - const charge = await this.repo().findOne({ - where: { id: chargeId, bookingId }, - }); - if (!charge) throw new NotFoundException('Clearance charge not found'); - if (charge.status !== 'BILLED') { + const charge = await this.findCharge(bookingId, chargeId); + if (charge.status !== 'BILLED' && charge.status !== 'REJECTED') { throw new ConflictException( - 'Set the amount and currency before sending the charge to the customer.', + charge.status === 'DOC_UPLOADED' + ? 'Set the amount and currency before sending the charge to the customer.' + : 'This charge has already been sent to the customer.', ); } + const revised = charge.status === 'REJECTED'; + const amount = Number(charge.amount); + const currency = charge.currency ?? 'ETB'; + await this.repo().update(charge.id, { + status: 'SENT', + customerNote: null, + customerDecidedAt: null, + customerDecidedBy: null, + }); + await this.clearanceEvents.record({ + bookingId, + action: 'CHARGE_SENT', + label: `${revised ? 'Re-sent' : 'Sent'} ${CHARGE_LABEL[ + charge.type + ].toLowerCase()} to the customer for approval: ${amount} ${currency}`, + actorId: staffId ?? null, + metadata: { + chargeType: charge.type, + amount, + currency, + description: charge.description ?? null, + revised, + }, + }); const booking = await this.bookingsService.findById(bookingId); + this.notifier.clearanceChargeProposed(booking, { + label: CHARGE_LABEL[charge.type], + amount, + currency, + description: charge.description ?? null, + revised, + }); + return this.list(bookingId); + } + + /** Customer agrees to the price: the payable invoice is issued and the charge locks. */ + async customerAccept( + bookingId: string, + chargeId: string, + userId: string, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + await this.bookingsService.assertCustomerCanAccessBooking(userId, booking); + const charge = await this.findCharge(bookingId, chargeId); + if (charge.status !== 'SENT' && charge.status !== 'REJECTED') { + throw new ConflictException( + charge.status === 'ACCEPTED' || charge.status === 'PAID' + ? 'This charge has already been accepted.' + : 'This charge is not awaiting your decision.', + ); + } + const amount = Number(charge.amount); + const currency = charge.currency ?? 'ETB'; const invoice = await this.billing.generateInvoice({ source: Freight.InvoiceSource.ClearanceCharge, // The charge's own id, NOT the booking id — booking-scoped invoice @@ -258,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 { + const booking = await this.bookingsService.findById(bookingId); + await this.bookingsService.assertCustomerCanAccessBooking(userId, booking); + const charge = await this.findCharge(bookingId, chargeId); + if (charge.status !== 'SENT') { + throw new ConflictException( + charge.status === 'ACCEPTED' || charge.status === 'PAID' + ? 'This charge has already been accepted.' + : 'This charge is not awaiting your decision.', + ); + } + if (!note?.trim()) { + throw new BadRequestException('Say why you are rejecting this charge.'); + } + await this.repo().update(charge.id, { + status: 'REJECTED', + customerNote: note.trim(), + customerDecidedAt: new Date(), + customerDecidedBy: userId, + }); + await this.clearanceEvents.record({ + bookingId, + action: 'CHARGE_REJECTED', + label: `Customer rejected ${CHARGE_LABEL[charge.type].toLowerCase()}: ${note.trim()}`, + actorType: 'CUSTOMER', + actorId: userId, + metadata: { chargeType: charge.type, note: note.trim() }, + }); + this.notifier.clearanceChargeRejectedToStaff(booking, { + label: CHARGE_LABEL[charge.type], + note: note.trim(), + }); + return this.listForCustomer(bookingId); } /** - * GL Ethiopia creates the miscellaneous charge whole (document + amount + - * currency). Second payment level: allowed only once the port charge is paid. + * GL Ethiopia creates a miscellaneous charge whole (document + amount + + * currency + what it is for). Lands as a BILLED draft; GL sends it next. */ async createMiscellaneous( bookingId: string, file: Express.Multer.File, - input: { amount: number; currency: string }, + input: { amount: number; currency: string; description?: string }, staffId: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); @@ -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, }, }); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.spec.ts new file mode 100644 index 000000000..ac1a5a225 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.spec.ts @@ -0,0 +1,22 @@ +import { + CUSTOMER_VISIBLE_CHARGE_STATUSES, + canStaffEditCharge, +} from './booking-clearance-charge.service'; +import { CLEARANCE_CHARGE_STATUSES } from './entities/booking-clearance-charge.entity'; + +describe('clearance charge status guards', () => { + it('locks the charge once the customer has accepted or paid', () => { + expect(canStaffEditCharge('ACCEPTED')).toBe(false); + expect(canStaffEditCharge('PAID')).toBe(false); + for (const s of ['DOC_UPLOADED', 'BILLED', 'SENT', 'REJECTED'] as const) { + expect(canStaffEditCharge(s)).toBe(true); + } + }); + + it('hides GL drafts from the customer and shows everything sent', () => { + const visible = CLEARANCE_CHARGE_STATUSES.filter((s) => + CUSTOMER_VISIBLE_CHARGE_STATUSES.has(s), + ); + expect(visible).toEqual(['SENT', 'REJECTED', 'ACCEPTED', 'PAID']); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index f457e782a..ad0e62ef3 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -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.`; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-payables.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-payables.service.ts new file mode 100644 index 000000000..e815fe929 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-payables.service.ts @@ -0,0 +1,107 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { Freight } from '@edr/types'; + +/** Invoice statuses a customer can still settle (mirrors the portal's PAYABLE_STATUSES). */ +const PAYABLE_INVOICE_STATUSES = ['ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE']; +/** Booking statuses at which the freight invoice is actually due (mirrors BookingsService). */ +const FREIGHT_PAYABLE_BOOKING_STATUSES = [ + 'FULLY_EXECUTED', + 'SELECTED_FOR_BATCH', + 'AWAITING_PAYMENT', +]; + +/** + * One row per outstanding item. `invoices.status` / `bookings.status` are + * Postgres enums, hence the ::text casts. `amount` is NULL for items that only need the + * customer's review (a proposed clearance charge, a draft final invoice) so + * they count but do not inflate "amount due". + */ +const SQL = ` + -- Central invoices on the booking: freight (only while the booking is in a + -- payable status), wagon-cancellation fee, GL final invoice (+ its DRAFT, + -- which waits for the customer's approval). + SELECT i.source_id AS "bookingId", i.currency, + CASE WHEN i.status::text = 'DRAFT' THEN NULL ELSE i.balance_amount END AS amount + FROM freight.invoices i + JOIN freight.bookings b ON b.id::text = i.source_id AND b.deleted_at IS NULL + WHERE i.company_id = $1 AND i.deleted_at IS NULL AND i.source = 'booking' + AND ( + (i.status::text = ANY($2::text[]) AND i.balance_amount > 0 + AND (i.type IN ('WAGON_CANCEL_FEE', 'GL_FINAL') OR b.status::text = ANY($3::text[]))) + OR (i.type = 'GL_FINAL' AND i.status::text = 'DRAFT') + ) + UNION ALL + -- Accepted clearance charges whose invoice is still unpaid. + SELECT c.booking_id::text, i.currency, i.balance_amount + FROM freight.invoices i + JOIN freight.booking_clearance_charge c ON c.id::text = i.source_id AND c.deleted_at IS NULL + WHERE i.company_id = $1 AND i.deleted_at IS NULL AND i.source = 'clearance_charge' + AND i.status::text = ANY($2::text[]) AND i.balance_amount > 0 + UNION ALL + -- Clearance charges waiting for the customer to accept or reject the price. + SELECT c.booking_id::text, c.currency, NULL::numeric + FROM freight.booking_clearance_charge c + JOIN freight.bookings b ON b.id = c.booking_id AND b.deleted_at IS NULL + WHERE b.company_id = $1 AND c.deleted_at IS NULL AND c.status = 'SENT' + UNION ALL + -- Duty / tax advised by customs, payment slip not uploaded yet. + SELECT m.booking_id::text, m.metadata->>'dutyCurrency', + NULLIF(m.metadata->>'dutyAmount', '')::numeric + FROM freight.clearance_milestones m + JOIN freight.bookings b ON b.id = m.booking_id AND b.deleted_at IS NULL + WHERE b.company_id = $1 AND m.deleted_at IS NULL AND m.status = 'COMPLETED' + AND ( + (m.milestone_code = 'DUTY_TAXES_ADVISED' AND NOT EXISTS ( + SELECT 1 FROM freight.clearance_milestones p + WHERE p.booking_id = m.booking_id AND p.milestone_code = 'DUTY_TAX_PAID' + AND p.status = 'COMPLETED' AND p.deleted_at IS NULL)) + OR + (m.milestone_code = 'SECOND_DUTY_ADVISED' AND NOT EXISTS ( + SELECT 1 FROM freight.clearance_milestones p + WHERE p.booking_id = m.booking_id AND p.milestone_code = 'SECOND_DUTY_PAID' + AND p.status = 'COMPLETED' AND p.deleted_at IS NULL)) + ) +`; + +/** + * Everything a customer still has to act on, per booking, in one query. Drives + * the "Pay" badge on the home and booking-list rows; the booking's Payments tab + * composes the same items client-side from the per-booking endpoints. + */ +@Injectable() +export class BookingPayablesService { + constructor(private readonly dataSource: DataSource) {} + + async summarizeForCompany( + companyId: string, + ): Promise { + const rows: Array<{ + bookingId: string; + currency: string | null; + amount: string | null; + }> = await this.dataSource.query(SQL, [ + companyId, + PAYABLE_INVOICE_STATUSES, + FREIGHT_PAYABLE_BOOKING_STATUSES, + ]); + + const byBooking = new Map(); + for (const r of rows) { + const s = byBooking.get(r.bookingId) ?? { + bookingId: r.bookingId, + count: 0, + totals: [], + }; + s.count += 1; + const amount = Number(r.amount ?? 0); + if (r.currency && amount > 0) { + const t = s.totals.find((x) => x.currency === r.currency); + if (t) t.amount += amount; + else s.totals.push({ currency: r.currency, amount }); + } + byBooking.set(r.bookingId, s); + } + return [...byBooking.values()]; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index 75a179b5c..ba1aaa875 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -57,6 +57,7 @@ describe('BookingPricingService — domestic corridor', () => { exchangeService as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, {} as never, + { findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never, ); }); @@ -333,6 +334,7 @@ describe('BookingPricingService — customs clearance fee billed on the booking : [], }), } as never, + { findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never, ); const containerBooking = (overrides: Record = {}) => @@ -388,6 +390,30 @@ describe('BookingPricingService — customs clearance fee billed on the booking expect(line!.amount).toBe(200); }); + it('prices an Ethiopian-customs-only service off ETHIOPIAN_CUSTOMS_CLEARANCE, not the full fee', async () => { + const ethiopianFee = { + ...containerFee20, + id: 'rate-et-20', + rateType: 'ETHIOPIAN_CUSTOMS_CLEARANCE', + trigger: 'ETHIOPIAN_CUSTOMS_CLEARANCE', + rateValue: 40, + } as Rate; + // No serviceType relation on the booking (like the GL/portal shipment + // preview) — the flag must be resolved from serviceTypeId. + const service = makeService({ liveRates: [containerFee20, ethiopianFee] }); + (service as unknown as { serviceTypesService: { findById: jest.Mock } }).serviceTypesService = { + findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: true }), + }; + const result = await service.computePriceForBooking( + containerBooking({ serviceTypeId: 'st-et', serviceType: undefined }), + ); + + const line = result.lineItems.find((l) => l.code === 'ETHIOPIAN_CUSTOMS_CLEARANCE_20FT'); + expect(line).toBeDefined(); + expect(line!.amount).toBe(160); + expect(result.lineItems.some((l) => l.code === 'CUSTOMS_CLEARANCE_20FT')).toBe(false); + }); + it('hard-blocks a container type with no fee configured (never free clearance)', async () => { const service = makeService({ liveRates: [bulkFeePerTon] }); const result = await service.computePriceForBooking(containerBooking()); @@ -553,6 +579,7 @@ describe('BookingPricingService — bulk base freight units', () => { wagonTypes: wagonCapacity !== undefined ? [{ capacityTons: wagonCapacity }] : [], }), } as never, + { findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never, ); // 12 machines, not 12 tonnes — a PER_ITEM commodity records its count here. @@ -683,6 +710,7 @@ describe('BookingPricingService — PER_WAGON container freight', () => { { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, { findById: jest.fn() } as never, + { findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never, ); const booking = ( diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index f7dc40a82..29104bbce 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -3,6 +3,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { CargoTypesService } from '../rule-engine/services/cargo-types.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RatesService } from '../rule-engine/services/rates.service'; +import { ServiceTypesService } from '../rule-engine/services/service-types.service'; import { Rate } from '../rule-engine/entities/rate.entity'; import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util'; import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; @@ -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 " #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 { @@ -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, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts index 6496208e7..ada450c50 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts @@ -63,7 +63,7 @@ describe('BookingTransitionService — paired staff decisions', () => { expect(result.partner.id).toBe('b-2'); }); - it('cancels both halves with the same reason', async () => { + it('cancels via cancel() once — its pair cascade settles the partner', async () => { const { service } = makeService(paired); const cancel = jest .spyOn(service, 'cancel') @@ -73,21 +73,23 @@ describe('BookingTransitionService — paired staff decisions', () => { reason: 'customer withdrew', }); - expect(cancel).toHaveBeenNthCalledWith(1, 'b-1', 'customer withdrew'); - expect(cancel).toHaveBeenNthCalledWith(2, 'b-2', 'customer withdrew'); + expect(cancel).toHaveBeenCalledTimes(1); + expect(cancel).toHaveBeenCalledWith('b-1', 'customer withdrew'); }); it('propagates a failure on the second half so neither is committed', async () => { const { service, dataSource } = makeService(paired); jest - .spyOn(service, 'cancel') + .spyOn(service, 'acceptIntake') .mockImplementationOnce(async (id) => ({ id }) as Booking) .mockImplementationOnce(async () => { throw new Error('partner is already in transit'); }); await expect( - service.applyPairedDecision('b-1', 'cancel', 'staff-1', { reason: 'x' }), + service.applyPairedDecision('b-1', 'accept', 'staff-1', { + validityDays: 30, + }), ).rejects.toThrow('partner is already in transit'); // Both halves ran inside one transaction, so the throw rolls the first back. diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 6d4d9073b..264ffb811 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -28,6 +28,7 @@ import { ContainerValidationService } from './container-validation.service'; import { BookingsRepository } from './bookings.repository'; import { assertBookingStatus } from './booking-status.util'; import { + adHocLabel, clearanceCodesForBooking, clearanceDocumentsOpen, } from './clearance.util'; @@ -90,28 +91,10 @@ export class BookingTransitionService { /** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */ private async assert20ftPairable(booking: Booking): Promise { - // Parity gate. 20ft ride two per wagon, so an odd total leaves one container - // that cannot be placed. Consolidation (pairing it with another customer's - // odd booking) is built end to end but switched off for now, so an odd total - // is rejected here rather than parked for a partner. - // containerSize is not always populated (some rows carry only the container - // type), so fall back to the type's sizeFt rather than silently skipping - // those lines and letting an odd booking through. - const ft20Quantity = (booking.bookingContainers ?? []) - .filter((bc) => - bc.containerSize - ? bc.containerSize.includes("20") - : Number(bc.containerType?.sizeFt) === 20, - ) - .reduce((sum, bc) => sum + Number(bc.quantity || 0), 0); - if (ft20Quantity % 2 === 1) { - throw new BadRequestException( - `20ft containers travel two per wagon, so they must be booked in even ` + - `numbers. This booking has ${ft20Quantity} — add one more or remove ` + - `one (book ${ft20Quantity + 1} or ${ft20Quantity - 1}).`, - ); - } - + // Odd 20ft totals are not rejected here: runConsolidationOnSubmit (called + // right after this gate) auto-pairs the odd leftover with another + // customer's odd booking or parks the booking as PENDING_CONSOLIDATION. + // Only the weight-pairing rule hard-blocks. const violations = await this.containerValidationService.validate20ftPairing(booking); if (violations.length) { @@ -455,11 +438,44 @@ export class BookingTransitionService { async cancelHold(bookingId: string, reason?: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ["SELECTED_FOR_BATCH"]); - if (booking.consolidationPartnerId) { - throw new BadRequestException( - "This booking shares a consolidated wagon with another booking — " + - "contact support to cancel it.", + // Consolidated pair: the shared wagon dies with this hold. An unpaid + // partner's hold is released with it (both cancel, no fee); a PAID partner + // cannot board alone, so the partnerLapsed listener cancels it too, with + // the cancellation fee — this unpaid canceller owes nothing (fees only + // apply to paid bookings). + const partnerId = booking.consolidationPartnerId; + if (partnerId) { + const partner = await this.bookingsService.findById(partnerId); + const partnerPaid = + partner.paymentStatus === "PAID" || partner.status === "PAID"; + await this.bookingsRepository.clearConsolidationPair( + booking.id, + partnerId, ); + if (partnerPaid) { + this.events.emit("booking.consolidation.partnerLapsed", { + paidBookingId: partnerId, + }); + } else if (!["CANCELLED", "EXPIRED"].includes(partner.status)) { + const partnerReason = "Cancelled with its consolidation partner"; + await this.bookingsRepository.createReviewNote( + partnerId, + partnerReason, + "REJECTION", + ); + if (partner.status === "SELECTED_FOR_BATCH") { + await this.bookingBatchService.cancelReservation(partnerId); + } else { + await this.invoiceService.expireOpenInvoices(partnerId); + await this.bookingsRepository.update(partnerId, { + status: "CANCELLED", + } as never); + } + this.notifier.cancelled( + await this.bookingsService.findById(partnerId), + partnerReason, + ); + } } await this.bookingsRepository.createReviewNote( bookingId, @@ -513,6 +529,17 @@ export class BookingTransitionService { ); } + // cancel() carries its own pair cascade (it settles the partner too), so + // running it twice would trip on the already-cancelled partner. + if (decision === "cancel") { + const own = await this.cancel( + bookingId, + options.reason ?? "Cancelled with its consolidation partner", + ); + const other = await this.bookingsService.findById(partnerId); + return { booking: own, partner: other }; + } + const runOne = async (id: string): Promise => { switch (decision) { case "accept": @@ -524,11 +551,6 @@ export class BookingTransitionService { ); } return this.acceptIntake(id, actorId, Number(options.validityDays)); - case "cancel": - return this.cancel( - id, - options.reason ?? "Cancelled with its consolidation partner", - ); case "operationAccept": return this.reviewOperationRequest(id, "ACCEPT", actorId, { note: options.note, @@ -565,8 +587,54 @@ export class BookingTransitionService { "PENDING_APPROVAL", "CONTRACT_READY", "OPERATION_REQUEST_PENDING", + // A booking parked waiting for a consolidation partner can be walked + // away from — nothing is reserved yet. + "PENDING_CONSOLIDATION", ]); + // Consolidated pair: a shared wagon never ships half-full, so cancelling + // one half settles the other too. Neither paid → both cancel, no fee. A + // PAID partner cannot board alone, so the partnerLapsed listener cancels + // it too, with the cancellation fee — the unpaid canceller owes nothing + // (fees only apply to paid bookings). A PAID booking itself never comes + // through here (status gate above) — it cancels via wagon cancellation, + // where the fee machinery lives. + const partnerId = booking.consolidationPartnerId; + if (partnerId) { + const partner = await this.bookingsService.findById(partnerId); + const partnerPaid = + partner.paymentStatus === "PAID" || partner.status === "PAID"; + await this.bookingsRepository.clearConsolidationPair( + booking.id, + partnerId, + ); + if (partnerPaid) { + this.events.emit("booking.consolidation.partnerLapsed", { + paidBookingId: partnerId, + }); + } else if (!["CANCELLED", "EXPIRED"].includes(partner.status)) { + const partnerReason = "Cancelled with its consolidation partner"; + await this.bookingsRepository.createReviewNote( + partnerId, + partnerReason, + "REJECTION", + ); + await this.invoiceService.expireOpenInvoices(partnerId); + if (partner.status === "SELECTED_FOR_BATCH") { + // Reserved hold: release the wagons through the batch engine. + await this.bookingBatchService.cancelReservation(partnerId); + } else { + await this.bookingsRepository.update(partnerId, { + status: "CANCELLED", + } as never); + } + this.notifier.cancelled( + await this.bookingsService.findById(partnerId), + partnerReason, + ); + } + } + await this.bookingsRepository.createReviewNote( bookingId, reason, @@ -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_