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/src/config/eims.config.spec.ts b/apps/edr-freight-api/src/config/eims.config.spec.ts index 127b3ea62..a6aa3895d 100644 --- a/apps/edr-freight-api/src/config/eims.config.spec.ts +++ b/apps/edr-freight-api/src/config/eims.config.spec.ts @@ -70,62 +70,3 @@ describe("eims.config — private key / certificate resolution", () => { ); }); }); - -describe("eims.config — baked-in Ethiopia region/zone/woreda codes", () => { - it("resolves a known region/wereda/zone with no env var set at all", () => { - withEnv( - { ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" }, - () => { - const cfg = eimsConfigFactory(); - expect(cfg.invoice.buyerRegionCodes.Somali).toBe("05"); - expect(cfg.invoice.buyerWeredaCodes["Jijiga Town"]).toBe("02"); - expect(cfg.invoice.buyerCityCodes.Fafan).toBe("01"); - }, - ); - }); - - it("an env var entry overrides the baked-in code for the same name", () => { - withEnv( - { - ...REQUIRED, - EIMS_PRIVATE_KEY: "x", - EIMS_CERTIFICATE_PATH: "/dev/null", - EIMS_BUYER_REGION_CODES: "Somali=99", - }, - () => { - expect(eimsConfigFactory().invoice.buyerRegionCodes.Somali).toBe("99"); - }, - ); - }); - - it("an env var still adds a name the baked-in table doesn't have (a spelling variant)", () => { - withEnv( - { - ...REQUIRED, - EIMS_PRIVATE_KEY: "x", - EIMS_CERTIFICATE_PATH: "/dev/null", - EIMS_BUYER_CITY_CODES: "Fafen=01", - }, - () => { - const codes = eimsConfigFactory().invoice.buyerCityCodes; - expect(codes.Fafen).toBe("01"); - expect(codes.Fafan).toBe("01"); // baked-in entry still present alongside it - }, - ); - }); - - it("resolves the bare Addis Ababa sub-city name a buyer profile actually stores, not the CSV's example-woreda name", () => { - withEnv( - { ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" }, - () => { - const codes = eimsConfigFactory().invoice.buyerWeredaCodes; - expect(codes.Bole).toBe("01"); - expect(codes.Arada).toBe("01"); - expect(codes.Kirkos).toBe("01"); - expect(codes.Yeka).toBe("01"); - expect(codes["Nifas Silk Lafto"]).toBe("13"); - expect(codes["Nefas Silk-Lafto"]).toBe("13"); - }, - ); - }); -}); diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index e5530eaf2..e0140ba68 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -1,6 +1,5 @@ import { registerAs } from "@nestjs/config"; -import { ETHIOPIA_REGION_CODES, ETHIOPIA_WOREDA_CODES, ETHIOPIA_ZONE_CODES } from "./ethiopia-geo-codes"; /** * Ethiopian MoR EIMS e-invoicing gateway. @@ -99,35 +98,6 @@ export interface EimsInvoiceConfig { paymentMode: string; paymentTerm: string; unitDefault: string; - /** - * Domestic fallback only — used when the buyer's `Company.country` is empty or "Ethiopia" (the - * column's own default) and not already listed in `buyerCountryCodes`. A genuinely foreign - * buyer must be in `buyerCountryCodes` by name or the mapping fails locally; this value is never - * applied to them, so an unconfigured foreign country can't silently be filed as Ethiopia. - */ - buyerCountryCode: string | null; - /** - * Country name → MoR code, from `EIMS_BUYER_COUNTRY_CODES` ("Ethiopia=231,Djibouti=071"). Format - * unconfirmed (unlike Region/Wereda, MoR has never named a Country regex), so — unlike them — - * this is not validated against a fixed digit pattern, only looked up by name. - */ - buyerCountryCodes: Record; - /** - * Buyer region name → MoR numeric code, from `EIMS_BUYER_REGION_CODES` - * ("Addis Ababa=13,Oromia=4"). A buyer whose region is neither a code nor in this map fails - * locally rather than being filed with a guessed one. - */ - buyerRegionCodes: Record; - /** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */ - buyerWeredaCodes: Record; - /** - * Buyer *zone* name → MoR City code, from `EIMS_BUYER_CITY_CODES` ("Kirkos=101"). `Company` has - * no dedicated city column — Zone is the closest match in EDR's own data. Optional, unlike - * Region/Wereda: MoR has never required City on a live buyer (confirmed — filing already - * succeeds with it null), so an unmapped zone falls back to null rather than failing the - * mapping. - */ - buyerCityCodes: Record; /** * Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` + * `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to @@ -258,13 +228,6 @@ export default registerAs("eims", (): EimsConfig => { paymentMode: process.env.EIMS_PAYMENT_MODE ?? "", paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "", unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "", - buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null, - buyerCountryCodes: parseCodeMap(process.env.EIMS_BUYER_COUNTRY_CODES), - // Baked-in Ethiopia reference table first, env var entries win on a name collision — lets a - // deployment override or add to it without a redeploy. See ethiopia-geo-codes.ts. - buyerRegionCodes: { ...ETHIOPIA_REGION_CODES, ...parseCodeMap(process.env.EIMS_BUYER_REGION_CODES) }, - buyerWeredaCodes: { ...ETHIOPIA_WOREDA_CODES, ...parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES) }, - buyerCityCodes: { ...ETHIOPIA_ZONE_CODES, ...parseCodeMap(process.env.EIMS_BUYER_CITY_CODES) }, taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE), taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE), exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE), diff --git a/apps/edr-freight-api/src/config/ethiopia-geo-codes.ts b/apps/edr-freight-api/src/config/ethiopia-geo-codes.ts deleted file mode 100644 index ca48a7c5f..000000000 --- a/apps/edr-freight-api/src/config/ethiopia-geo-codes.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * MoR EIMS region/zone/woreda codes, by name — the baked-in fallback under - * `EIMS_BUYER_REGION_CODES`/`EIMS_BUYER_WEREDA_CODES`/`EIMS_BUYER_CITY_CODES` (zone is the closest - * match to EIMS's "City", per `eims-invoice.mapper.ts`). - * - * Before this existed, every buyer from a not-yet-seen region/zone/woreda crashed EIMS filing until - * someone hunted down the code and added it to an env var by hand — happened three times in one - * afternoon (2026-08-17: Somali region, Fafan zone, Jigjiga woreda, even the Ethiopia country code - * itself were all unset). Ethiopia's administrative divisions are fixed, known, reference data, not - * something that should be maintained reactively per buyer. Source: `ethiopia_administrative_ - * hierarchy_master.csv`, supplied 2026-08-17 — NOT exhaustive (a representative sample per region, - * not all ~1000 real woredas), extend as new gaps surface. - * - * The env vars stay wired in ahead of this table (see `eims.config.ts`) — for a quick correction - * without a redeploy, or a name spelled differently in a buyer's profile than in this table (already - * hit live: DB has zone "Fafen", this table's official spelling is "Fafan" — same zone, matching is - * case/space-insensitive but not spelling-tolerant, so the env var override is still how that buyer - * actually resolves; this table mainly helps the *next* buyer whose profile spelling matches). - * - * ponytail: region names are unique nationwide (only ~15), safe as a flat map. Zone and woreda names - * are not always unique across different regions (e.g. "North Shewa" is both an Amhara zone and an - * Oromia zone, different codes) — `Company` stores region/zone/woreda as three independent strings, - * no parent linkage, so a flat name lookup can't disambiguate. First occurrence in the source data - * wins on a collision. Only affects the optional `City` field (zone) — never blocks filing, unlike - * Region/Wereda. A correct fix needs `Company` to store a linked hierarchy, not just three strings; - * out of scope here. Upgrade path: key this by `${region}/${zone}` once that linkage exists. - */ -const ROWS: Array<[region: string, zone: string, woreda: string, regionCode: string, zoneCode: string, woredaCode: string]> = [ - ["Tigray", "Western Tigray", "Humera", "01", "01", "01"], - ["Tigray", "Western Tigray", "Kafta Humera", "01", "01", "02"], - ["Tigray", "Western Tigray", "Tsegede", "01", "01", "03"], - ["Tigray", "North Western Tigray", "Shire Endaselassie", "01", "02", "01"], - ["Tigray", "North Western Tigray", "Sheraro", "01", "02", "02"], - ["Tigray", "Central Tigray", "Axum", "01", "03", "01"], - ["Tigray", "Central Tigray", "Adwa", "01", "03", "02"], - ["Tigray", "Eastern Tigray", "Adigrat", "01", "04", "01"], - ["Tigray", "Southern Tigray", "Maychew", "01", "05", "01"], - ["Tigray", "Mekelle Special Zone", "Mekelle City", "01", "06", "01"], - ["Afar", "Awusi Rasu (Zone 1)", "Asayita", "02", "01", "01"], - ["Afar", "Awusi Rasu (Zone 1)", "Semera-Logiya", "02", "01", "02"], - ["Afar", "Kilbet Rasu (Zone 2)", "Abala", "02", "02", "01"], - ["Afar", "Gabi Rasu (Zone 3)", "Awash Fentale", "02", "03", "01"], - ["Afar", "Fantena Rasu (Zone 4)", "Yalo", "02", "04", "01"], - ["Afar", "Hari Rasu (Zone 5)", "Telalak", "02", "05", "01"], - ["Amhara", "North Gondar", "Debark", "03", "01", "01"], - ["Amhara", "South Gondar", "Debre Tabor", "03", "02", "01"], - ["Amhara", "North Wollo", "Woldiya", "03", "03", "01"], - ["Amhara", "South Wollo", "Dessie Town", "03", "04", "01"], - ["Amhara", "North Shewa", "Debre Berhan", "03", "05", "01"], - ["Amhara", "East Gojjam", "Debre Markos", "03", "06", "01"], - ["Amhara", "West Gojjam", "Finote Selam", "03", "07", "01"], - ["Amhara", "Wag Hemra", "Sekota", "03", "08", "01"], - ["Amhara", "Awi", "Injibara", "03", "09", "01"], - ["Amhara", "Oromia Special Zone", "Kemise", "03", "10", "01"], - ["Amhara", "Bahir Dar Special Zone", "Bahir Dar City", "03", "11", "01"], - ["Amhara", "Gondar Special Zone", "Gondar City", "03", "12", "01"], - ["Oromia", "North Shewa", "Fiche", "04", "01", "01"], - ["Oromia", "South West Shewa", "Waliso", "04", "02", "01"], - ["Oromia", "East Shewa", "Adama Town", "04", "03", "01"], - ["Oromia", "East Shewa", "Bishoftu Town", "04", "03", "02"], - ["Oromia", "West Shewa", "Ambo", "04", "04", "01"], - ["Oromia", "Arsi", "Asella", "04", "05", "01"], - ["Oromia", "West Arsi", "Shashemene", "04", "06", "01"], - ["Oromia", "Bale", "Robe", "04", "07", "01"], - ["Oromia", "East Hararghe", "Harar Outskirts", "04", "08", "01"], - ["Oromia", "West Hararghe", "Chiro", "04", "09", "01"], - ["Oromia", "Jimma", "Jimma Town", "04", "10", "01"], - ["Oromia", "Illubabor", "Mettu", "04", "11", "01"], - ["Oromia", "Buno Bedele", "Bedele", "04", "12", "01"], - ["Oromia", "Welega (West)", "Gimbi", "04", "13", "01"], - ["Oromia", "Welega (East)", "Nekemte", "04", "14", "01"], - ["Oromia", "Horo Guduru Welega", "Shambu", "04", "15", "01"], - ["Oromia", "Kelam Welega", "Dembidolo", "04", "16", "01"], - ["Oromia", "Borena", "Yabelo", "04", "17", "01"], - ["Oromia", "Guji", "Negele Borana", "04", "18", "01"], - ["Oromia", "West Guji", "Bule Hora", "04", "19", "01"], - ["Oromia", "East Bale", "Ginir", "04", "20", "01"], - ["Oromia", "Sheger City", "Sululta", "04", "21", "01"], - ["Somali", "Fafan", "Jijiga Woreda", "05", "01", "01"], - ["Somali", "Fafan", "Jijiga Town", "05", "01", "02"], - ["Somali", "Fafan", "Awbare", "05", "01", "03"], - ["Somali", "Sitti", "Shinile", "05", "02", "01"], - ["Somali", "Erer", "Fiq", "05", "03", "01"], - ["Somali", "Jarar", "Degehabur", "05", "04", "01"], - ["Somali", "Nogob", "Segeg", "05", "05", "01"], - ["Somali", "Korahe", "Kebridehar", "05", "06", "01"], - ["Somali", "Shabelle", "Gode", "05", "07", "01"], - ["Somali", "Afder", "Afder Woreda", "05", "08", "01"], - ["Somali", "Liben", "Filtu", "05", "09", "01"], - ["Somali", "Dhawa", "Mubarak", "05", "10", "01"], - ["Somali", "Dollo", "Warder", "05", "11", "01"], - ["Benishangul-Gumuz", "Asosa", "Asosa Woreda", "06", "01", "01"], - ["Benishangul-Gumuz", "Kamasashi", "Kamasashi Woreda", "06", "02", "01"], - ["Benishangul-Gumuz", "Metekel", "Gilgel Beles", "06", "03", "01"], - ["Southern Ethiopia", "Wolayta", "Sodo Zuria", "07", "01", "01"], - ["Southern Ethiopia", "Wolayta", "Sodo Town", "07", "01", "02"], - ["Southern Ethiopia", "Gamo", "Arba Minch Town", "07", "02", "01"], - ["Southern Ethiopia", "Gofa", "Sawla", "07", "03", "01"], - ["Southern Ethiopia", "Konso", "Konso Woreda", "07", "04", "01"], - ["Southern Ethiopia", "South Omo", "Jinka", "07", "05", "01"], - ["Gambela", "Anywaa", "Gambela Zuria", "08", "01", "01"], - ["Gambela", "Nuer", "Lare", "08", "02", "01"], - ["Gambela", "Majang", "Metu Zuria part", "08", "03", "01"], - ["Harari", "Harar Hundanee", "Amir Nur Woreda", "09", "01", "01"], - ["Harari", "Harar Hundanee", "Abadir Woreda", "09", "01", "02"], - ["Addis Ababa", "Bole Sub-City", "Bole Woreda 01", "10", "01", "01"], - ["Addis Ababa", "Kirkos Sub-City", "Kirkos Woreda 01", "10", "02", "01"], - ["Addis Ababa", "Nifas Silk Lafto", "NSL Woreda 13", "10", "03", "13"], - ["Addis Ababa", "Yeka Sub-City", "Yeka Woreda 01", "10", "04", "01"], - ["Addis Ababa", "Arada Sub-City", "Arada Woreda 01", "10", "05", "01"], - ["Dire Dawa", "Dire Dawa Urban", "Melka Jebdu", "11", "01", "01"], - ["Dire Dawa", "Dire Dawa Rural", "Gurgura", "11", "02", "01"], - ["Sidama", "Hawassa City Admin", "Hayek Chereka", "12", "01", "01"], - ["Sidama", "Sidama Zuria", "Yirgalem Town", "12", "02", "01"], - ["Sidama", "Sidama Zuria", "Aleta Wendo", "12", "02", "02"], - ["Southwest Ethiopia", "Keffa", "Bonga Town", "13", "01", "01"], - ["Southwest Ethiopia", "Sheka", "Mappi Zuria", "13", "02", "01"], - ["Southwest Ethiopia", "Bench Sheko", "Mizan Aman", "13", "03", "01"], - ["Central Ethiopia", "Gurage", "Wolkite", "14", "01", "01"], - ["Central Ethiopia", "Hadiya", "Hosaina", "14", "02", "01"], - ["Central Ethiopia", "Silte", "Worabe", "14", "03", "01"], - ["Gedeo State", "Gedeo Zone", "Dilla Zuria", "15", "01", "01"], - ["Gedeo State", "Gedeo Zone", "Yirgacheffe", "15", "01", "02"], -]; - -/** First occurrence wins on a name collision — see the class comment. */ -const buildMap = (pick: (row: (typeof ROWS)[number]) => [string, string]): Record => { - const map: Record = {}; - for (const row of ROWS) { - const [name, code] = pick(row); - if (!(name in map)) map[name] = code; - } - return map; -}; - -export const ETHIOPIA_REGION_CODES: Record = buildMap((r) => [r[0], r[3]]); -/** Zone name → code. Fed into `buyerCityCodes` — EIMS's "City" is really the buyer's zone. */ -export const ETHIOPIA_ZONE_CODES: Record = buildMap((r) => [r[1], r[4]]); -export const ETHIOPIA_WOREDA_CODES: Record = buildMap((r) => [r[2], r[5]]); - -/** - * Buyer records commonly store just the bare Addis Ababa sub-city name ("Bole", "Arada") as their - * woreda, not the source CSV's specific example-woreda name ("Bole Woreda 01") — confirmed live - * 2026-08-17 across three different buyers before any of them actually got past this check. Since - * the CSV lists exactly one representative woreda per Addis sub-city, alias the bare name to that - * same code rather than wait on a fuller table. - */ -const ADDIS_SUBCITY_ALIASES: Array<[bareName: string, csvZoneName: string]> = [ - ["Bole", "Bole Sub-City"], - ["Kirkos", "Kirkos Sub-City"], - ["Nifas Silk Lafto", "Nifas Silk Lafto"], - // Matches EIMS_BUYER_WEREDA_CODES' own existing spelling in .env — same zone, different hyphenation. - ["Nefas Silk-Lafto", "Nifas Silk Lafto"], - ["Yeka", "Yeka Sub-City"], - ["Arada", "Arada Sub-City"], -]; -for (const [bareName, csvZoneName] of ADDIS_SUBCITY_ALIASES) { - const row = ROWS.find((r) => r[1] === csvZoneName); - if (row && !(bareName in ETHIOPIA_WOREDA_CODES)) ETHIOPIA_WOREDA_CODES[bareName] = row[5]; -} diff --git a/apps/edr-freight-api/src/config/mor-location.resolver.spec.ts b/apps/edr-freight-api/src/config/mor-location.resolver.spec.ts new file mode 100644 index 000000000..0fc2607e2 --- /dev/null +++ b/apps/edr-freight-api/src/config/mor-location.resolver.spec.ts @@ -0,0 +1,275 @@ +import { MorLocationTuple } from "./mor-locations.data"; +import { + MorGeoMappingError, + normalizeName, + resolveMorGeo, + tryResolveMorGeo, +} from "./mor-location.resolver"; + +/** + * Rows copied verbatim out of the Ministry sheet (`EIMS_COUNTRY_REGION_VW`), chosen for the traps + * the real data contains rather than for tidiness: + * + * - BABILE and KERSA each exist in two different zones with different LOCALITY_NOs — the reason a + * global name lookup is unsafe and the hierarchy is mandatory. + * - ILLUBABOR has BURE twice under the same zone with different LOCALITY_NOs (691 and 890, the + * second with the Ministry's own trailing space) — a genuine ambiguity that must never be + * silently resolved to the first row. + * - "Wal-Mera" and "Akaki woreda" carry the sheet's mixed casing and punctuation. + */ +const FIXTURE: MorLocationTuple[] = [ + [70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 190, "JIJIGA"], + [70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 194, "BABILE"], + [70, "Ethiopia", 6, "SOMALI", 30, "SITI ZONE", 197, "DENBEL"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 495, "BABILE"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 482, "KERSA"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 503, "KERSA"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 691, "BURE"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 890, "BURE "], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 976, "Wal-Mera"], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 909, "Akaki woreda"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1100, "WOREDA 1"], + [253, "Djibouti", 1, "DJIBOUTI", 1, "DJIBOUTI VILLE", 1, "BALBALA"], +]; + +const JIJIGA = { + country: "Ethiopia", + region: "SOMALI", + zone: "FAAFAN ZONE", + woreda: "JIJIGA", +}; + +describe("normalizeName", () => { + it("collapses whitespace, trims, and compares case-insensitively", () => { + expect(normalizeName(" FAAFAN ZONE ")).toBe("FAAFAN ZONE"); + expect(normalizeName("faafan zone")).toBe("FAAFAN ZONE"); + expect(normalizeName(" FAAFAN ZONE ")).toBe(normalizeName("faafan zone")); + }); + + it("normalizes harmless punctuation and hyphen/space differences", () => { + expect(normalizeName("Wal-Mera")).toBe("WAL MERA"); + expect(normalizeName("Wal Mera")).toBe("WAL MERA"); + expect(normalizeName("ZONE 1 (AYSSAITA)")).toBe("ZONE 1 AYSSAITA"); + expect(normalizeName("Ber'ano")).toBe("BERANO"); + expect(normalizeName("KEAHORE/HADAT/")).toBe("KEAHORE HADAT"); + }); + + it("keeps digits, which several MoR locality names depend on", () => { + expect(normalizeName(" woreda 10 ")).toBe("WOREDA 10"); + expect(normalizeName("WOREDA 1")).not.toBe(normalizeName("WOREDA 10")); + }); +}); + +describe("resolveMorGeo", () => { + it("resolves the exact MoR spelling to the Ministry's own codes", () => { + expect(resolveMorGeo(JIJIGA, FIXTURE)).toEqual({ + Country: "70", + Region: "6", + City: "31", + Wereda: "190", + }); + }); + + it("resolves the EDR/e-Trade spellings through the alias layer", () => { + expect( + resolveMorGeo( + { + country: "Ethiopia", + region: "Somali", + zone: "Fafen", + woreda: "Jigjiga", + }, + FIXTURE, + ), + ).toEqual({ Country: "70", Region: "6", City: "31", Wereda: "190" }); + }); + + it("is case-insensitive", () => { + expect( + resolveMorGeo( + { + country: "ethiopia", + region: "somali", + zone: "faafan zone", + woreda: "jijiga", + }, + FIXTURE, + ), + ).toEqual({ Country: "70", Region: "6", City: "31", Wereda: "190" }); + }); + + it("ignores leading, trailing and repeated whitespace on every level", () => { + expect( + resolveMorGeo( + { + country: " Ethiopia ", + region: " SOMALI ", + zone: " FAAFAN ZONE ", + woreda: "\tJIJIGA ", + }, + FIXTURE, + ), + ).toEqual({ Country: "70", Region: "6", City: "31", Wereda: "190" }); + }); + + it("treats a hyphen as a space, in either direction", () => { + const expected = { Country: "70", Region: "2", City: "86", Wereda: "976" }; + const base = { + country: "Ethiopia", + region: "Oromia", + zone: "Finfine Vic Spec", + }; + expect(resolveMorGeo({ ...base, woreda: "Wal-Mera" }, FIXTURE)).toEqual(expected); + expect(resolveMorGeo({ ...base, woreda: "wal mera" }, FIXTURE)).toEqual(expected); + }); + + it("matches a zone whose MoR label carries the ' ZONE' suffix EDR does not store", () => { + expect(resolveMorGeo({ ...JIJIGA, zone: "Faafan" }, FIXTURE).City).toBe("31"); + expect( + resolveMorGeo( + { + country: "Ethiopia", + region: "Somali", + zone: "Siti", + woreda: "Denbel", + }, + FIXTURE, + ), + ).toEqual({ Country: "70", Region: "6", City: "30", Wereda: "197" }); + }); + + describe("a locality name that exists in more than one zone", () => { + it("picks BABILE by its full hierarchy, never by name alone", () => { + expect(resolveMorGeo({ ...JIJIGA, woreda: "BABILE" }, FIXTURE).Wereda).toBe("194"); + expect( + resolveMorGeo( + { + country: "Ethiopia", + region: "OROMIA", + zone: "MISRAK HARARGE", + woreda: "BABILE", + }, + FIXTURE, + ).Wereda, + ).toBe("495"); + }); + + it("picks KERSA by its full hierarchy", () => { + const oromia = { country: "Ethiopia", region: "OROMIA" }; + expect( + resolveMorGeo({ ...oromia, zone: "MISRAK HARARGE", woreda: "KERSA" }, FIXTURE).Wereda, + ).toBe("482"); + expect( + resolveMorGeo({ ...oromia, zone: "JIMMA ZONE", woreda: "KERSA" }, FIXTURE).Wereda, + ).toBe("503"); + }); + + it("does not let a locality leak across regions", () => { + // DENBEL exists under SOMALI/SITI ZONE only — asking for it under OROMIA must fail, not + // fall back to the nationwide match the old flat maps would have found. + expect(() => + resolveMorGeo( + { + country: "Ethiopia", + region: "OROMIA", + zone: "MISRAK HARARGE", + woreda: "DENBEL", + }, + FIXTURE, + ), + ).toThrow(/no MoR LOCALITY_DESC match/); + }); + }); + + describe("failures happen locally, before anything is filed", () => { + const cases: Array<[string, Record, RegExp]> = [ + ["unknown country", { ...JIJIGA, country: "Wakanda" }, /no MoR COUNTRY_NAME match/], + ["unknown region", { ...JIJIGA, region: "Atlantis" }, /no MoR PARISH_NAME match/], + ["unknown zone", { ...JIJIGA, zone: "Nowhere Zone" }, /no MoR CITY_NAME match/], + ["unknown woreda", { ...JIJIGA, woreda: "Example" }, /no MoR LOCALITY_DESC match/], + ]; + + it.each(cases)("%s fails with an actionable validation error", (_label, input, pattern) => { + expect(() => resolveMorGeo(input, FIXTURE)).toThrow(MorGeoMappingError); + expect(() => resolveMorGeo(input, FIXTURE)).toThrow(pattern); + }); + + it("names the offending address in the message so the company record can be corrected", () => { + expect(() => resolveMorGeo({ ...JIJIGA, woreda: "Example" }, FIXTURE)).toThrow( + /country="Ethiopia", region="SOMALI", zone="FAAFAN ZONE", woreda="Example"/, + ); + }); + + it("refuses an ambiguous locality instead of taking the first row", () => { + const input = { + country: "Ethiopia", + region: "OROMIA", + zone: "ILLUBABOR", + woreda: "BURE", + }; + expect(() => resolveMorGeo(input, FIXTURE)).toThrow(MorGeoMappingError); + expect(() => resolveMorGeo(input, FIXTURE)).toThrow(/ambiguous/); + // Both colliding codes are named, and neither is silently selected. + expect(() => resolveMorGeo(input, FIXTURE)).toThrow(/691, 890/); + expect(tryResolveMorGeo(input, FIXTURE)).toBeNull(); + }); + + it("fails loudly when the MoR master has not been generated yet", () => { + expect(() => resolveMorGeo(JIJIGA, [])).toThrow(/MoR location master is empty/); + }); + }); + + it("reproduces MoR's numeric values unchanged, as strings", () => { + const codes = resolveMorGeo(JIJIGA, FIXTURE); + expect(codes).toEqual({ + Country: "70", + Region: "6", + City: "31", + Wereda: "190", + }); + for (const value of Object.values(codes)) { + expect(typeof value).toBe("string"); + expect(value).toMatch(/^[0-9]+$/); + } + // The source row is the only origin of every code — no renumbering, no derivation. + const [countryNo, , parishNo, , cityNo, , localityNo] = FIXTURE[0]; + expect(codes).toEqual({ + Country: String(countryNo), + Region: String(parishNo), + City: String(cityNo), + Wereda: String(localityNo), + }); + }); + + it("never emits an Open Admin Data ETxx identifier", () => { + for (const value of Object.values(resolveMorGeo(JIJIGA, FIXTURE))) { + expect(value).not.toMatch(/^ET/i); + } + }); + + it("treats a blank country as domestic, matching the column default", () => { + expect(resolveMorGeo({ ...JIJIGA, country: "" }, FIXTURE).Country).toBe("70"); + expect(resolveMorGeo({ ...JIJIGA, country: null }, FIXTURE).Country).toBe("70"); + }); + + it("resolves a named foreign country rather than defaulting it to Ethiopia", () => { + expect( + resolveMorGeo( + { + country: "Djibouti", + region: "DJIBOUTI", + zone: "DJIBOUTI VILLE", + woreda: "BALBALA", + }, + FIXTURE, + ), + ).toEqual({ Country: "253", Region: "1", City: "1", Wereda: "1" }); + }); + + it("accepts a company record that already holds a MoR code, but only a real one", () => { + expect(resolveMorGeo({ ...JIJIGA, region: "6" }, FIXTURE).Region).toBe("6"); + expect(() => resolveMorGeo({ ...JIJIGA, region: "999" }, FIXTURE)).toThrow( + /no MoR PARISH_NAME match/, + ); + }); +}); diff --git a/apps/edr-freight-api/src/config/mor-location.resolver.ts b/apps/edr-freight-api/src/config/mor-location.resolver.ts new file mode 100644 index 000000000..72459db9b --- /dev/null +++ b/apps/edr-freight-api/src/config/mor-location.resolver.ts @@ -0,0 +1,255 @@ +import { BadRequestException } from "@nestjs/common"; + +import { MOR_LOCATIONS, MorLocationTuple } from "./mor-locations.data"; + +/** + * Resolves an EDR company address to the Ministry of Revenues' own EIMS location codes, using the + * MoR location master (`EIMS_COUNTRY_REGION_VW`) shipped in `mor-locations.data.ts`. + * + * MoR's field names do not line up with either EDR's or generic Ethiopian administrative datasets, + * so the mapping is fixed by the Ministry sheet, not by interpretation: + * + * Company.country -> COUNTRY_NAME -> COUNTRY_NO -> BuyerDetails.Country + * Company.region -> PARISH_NAME -> PARISH_NO -> BuyerDetails.Region + * Company.zone -> CITY_NAME -> CITY_NO -> BuyerDetails.City + * Company.woreda -> LOCALITY_DESC -> LOCALITY_NO -> BuyerDetails.Wereda + * + * This replaces the previous `EIMS_BUYER_*_CODES` environment maps and the `ethiopia-geo-codes.ts` + * table they layered over. Both invented their codes (sequential "01".."15" per region, from a + * generic administrative CSV) and both looked names up **globally**, which cannot be correct: + * KERSA, GORO, BABILE and BURE each occur in several different zones with different LOCALITY_NOs. + * A global name lookup silently picked the first, i.e. filed a real invoice against whichever tax + * jurisdiction happened to sort first. Resolution here is strictly hierarchical — each level is + * searched only within the rows its parent already selected. + * + * Open Admin Data identifiers (`ET14`, `ET0407`, …) are unrelated to this code system and must + * never appear in an EIMS payload; nothing in this module can emit one, since every returned value + * comes from a numeric column of the Ministry sheet. + */ + +export interface MorGeoCodes { + /** COUNTRY_NO as a string — `BuyerDetails.Country`. */ + Country: string; + /** PARISH_NO as a string — `BuyerDetails.Region`. */ + Region: string; + /** CITY_NO as a string — `BuyerDetails.City`. MoR calls the zone level "City". */ + City: string; + /** LOCALITY_NO as a string — `BuyerDetails.Wereda`. */ + Wereda: string; +} + +export interface MorAddressInput { + country?: string | null; + region?: string | null; + zone?: string | null; + woreda?: string | null; +} + +type Level = "country" | "region" | "zone" | "woreda"; + +/** Which tuple slots hold the name and the code at each level. */ +const SLOTS: Record = { + country: { name: 1, no: 0, column: "COUNTRY_NAME" }, + region: { name: 3, no: 2, column: "PARISH_NAME" }, + zone: { name: 5, no: 4, column: "CITY_NAME" }, + woreda: { name: 7, no: 6, column: "LOCALITY_DESC" }, +}; + +/** + * One normalized form for both sides of every comparison. Deliberately conservative: it removes + * differences that cannot change which jurisdiction is meant (case, stray and repeated whitespace, + * hyphen/slash/parenthesis/apostrophe punctuation, combining accents) and nothing else. There is + * no fuzzy or edit-distance matching anywhere in this module — a near-miss must fail loudly rather + * than file an invoice against a neighbouring woreda. + * + * " FAAFAN ZONE " -> "FAAFAN ZONE" + * "Wal-Mera" -> "WAL MERA" + * "Ber'ano" -> "BERANO" + * "ZONE 1 (AYSSAITA)"-> "ZONE 1 AYSSAITA" + */ +const normalizeCache = new Map(); +export function normalizeName(value: string | null | undefined): string { + const raw = value ?? ""; + const hit = normalizeCache.get(raw); + if (hit !== undefined) return hit; + const normalized = raw + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toUpperCase() + .replace(/['\u2018\u2019`]/g, "") + .replace(/[^A-Z0-9]+/g, " ") + .trim(); + normalizeCache.set(raw, normalized); + return normalized; +} + +/** + * Reviewed spelling differences between what EDR/e-Trade store and what the Ministry sheet calls + * the same place. Every entry is scoped to the administrative level it applies to, and to its + * parent where the name is not unique nationwide — so an alias can never reach across into another + * region's jurisdiction. `from`/`to` are compared normalized, so casing and spacing here are + * cosmetic. + * + * Add an entry only after confirming the two names are the same place in the Ministry sheet. This + * is the only sanctioned place for spelling compatibility; `mor-locations.data.ts` stays verbatim. + */ +interface MorAlias { + level: Exclude; + /** Parent scope, normalized-compared. Omit a level to leave the alias unscoped at that level. */ + region?: string; + zone?: string; + from: string; + to: string; +} + +const ALIASES: MorAlias[] = [ + // e-Trade and the customer portal both spell the Somali zone "Fafen"; MoR spells it "FAAFAN + // ZONE". Confirmed same zone (CITY_NO 31) — this is the buyer that first exposed the whole + // fabricated-code problem. + { level: "zone", region: "SOMALI", from: "Fafen", to: "FAAFAN ZONE" }, + // MoR's own capital of that zone is "JIJIGA"; every other source spells it "Jigjiga". + { + level: "woreda", + region: "SOMALI", + zone: "FAAFAN ZONE", + from: "Jigjiga", + to: "JIJIGA", + }, +]; + +/** + * MoR suffixes many zone labels with " ZONE" ("JIMMA ZONE", "FAAFAN ZONE", "SITI ZONE") while EDR + * stores the bare name. Retrying the suffixed spelling is an exact match against a second candidate + * string, scoped to the already-resolved region — not fuzzy matching — and it removes a long tail + * of otherwise hand-maintained aliases. Applied to the zone level only: locality suffixes + * ("WOREDA", "TOWN ADMINISTRATION") are not mechanical and could select a different place. + */ +const zoneSuffixCandidates = (normalized: string): string[] => + normalized.endsWith(" ZONE") ? [] : [`${normalized} ZONE`]; + +export class MorGeoMappingError extends BadRequestException { + constructor(code: "EIMS_GEO_MAPPING_FAILED" | "EIMS_GEO_AMBIGUOUS", message: string) { + super({ code, message }); + } +} + +/** Renders the address being resolved for an error message. No customer-identifying data. */ +const describe = (input: MorAddressInput): string => + `country="${input.country ?? ""}", region="${input.region ?? ""}", ` + + `zone="${input.zone ?? ""}", woreda="${input.woreda ?? ""}"`; + +function matchLevel( + rows: MorLocationTuple[], + level: Level, + raw: string | null | undefined, + parents: { region?: string; zone?: string }, + input: MorAddressInput, +): { no: number; rows: MorLocationTuple[] } { + const { name: nameSlot, no: noSlot, column } = SLOTS[level]; + const wanted = normalizeName(raw); + + const candidates: string[] = []; + if (wanted) { + candidates.push(wanted); + for (const alias of ALIASES) { + if (alias.level !== level) continue; + if (alias.region && normalizeName(alias.region) !== parents.region) continue; + if (alias.zone && normalizeName(alias.zone) !== parents.zone) continue; + if (normalizeName(alias.from) === wanted) candidates.push(normalizeName(alias.to)); + } + if (level === "zone") candidates.push(...zoneSuffixCandidates(wanted)); + } + + let matched: MorLocationTuple[] = []; + for (const candidate of candidates) { + matched = rows.filter((row) => normalizeName(row[nameSlot] as string) === candidate); + if (matched.length > 0) break; + } + + // A company record that already holds the MoR code itself resolves too — but only when that code + // genuinely exists at this level under this parent. An unvalidated numeric pass-through is how a + // wrong code reaches MoR without anything noticing. + if (matched.length === 0 && /^[0-9]{1,6}$/.test((raw ?? "").trim())) { + const asCode = Number((raw ?? "").trim()); + matched = rows.filter((row) => row[noSlot] === asCode); + } + + if (matched.length === 0) { + throw new MorGeoMappingError( + "EIMS_GEO_MAPPING_FAILED", + `EIMS geographic mapping failed: no MoR ${column} match for ${describe(input)}.`, + ); + } + + const distinct = [...new Set(matched.map((row) => row[noSlot] as number))]; + if (distinct.length > 1) { + throw new MorGeoMappingError( + "EIMS_GEO_AMBIGUOUS", + `EIMS geographic mapping is ambiguous: MoR ${column} "${(raw ?? "").trim()}" matches ` + + `${distinct.length} different codes (${distinct.sort((a, b) => a - b).join(", ")}) for ` + + `${describe(input)}. Correct the company address or the MoR reference data; an ambiguous ` + + "location is never filed.", + ); + } + + return { no: distinct[0], rows: matched }; +} + +/** + * Resolves the full hierarchy, or throws a `BadRequestException` naming the level that failed. + * + * Never guesses and never returns a partial result: an unknown or ambiguous location must stop the + * filing here, locally, before any MoR request and before an EIMS counter is consumed. + */ +export function resolveMorGeo( + input: MorAddressInput, + rows: MorLocationTuple[] = MOR_LOCATIONS, +): MorGeoCodes { + if (rows.length === 0) { + throw new MorGeoMappingError( + "EIMS_GEO_MAPPING_FAILED", + "EIMS geographic mapping failed: the MoR location master is empty. Generate it with " + + "`pnpm --filter @edr/freight-api eims:import-locations `.", + ); + } + + // `companies.country` defaults to 'Ethiopia' and is often left blank on older rows; blank means + // domestic here, exactly as the column default says. A *named* foreign country is resolved like + // any other and fails if MoR does not list it — it is never quietly filed as Ethiopia. + const country = (input.country ?? "").trim() || "Ethiopia"; + + const inCountry = matchLevel(rows, "country", country, {}, input); + const inRegion = matchLevel(inCountry.rows, "region", input.region, {}, input); + const regionScope = normalizeName(inRegion.rows[0][SLOTS.region.name] as string); + const inZone = matchLevel(inRegion.rows, "zone", input.zone, { region: regionScope }, input); + const zoneScope = normalizeName(inZone.rows[0][SLOTS.zone.name] as string); + const inWoreda = matchLevel( + inZone.rows, + "woreda", + input.woreda, + { + region: regionScope, + zone: zoneScope, + }, + input, + ); + + return { + Country: String(inCountry.no), + Region: String(inRegion.no), + City: String(inZone.no), + Wereda: String(inWoreda.no), + }; +} + +/** Non-throwing variant for callers that already have a working fallback (the seller identity). */ +export function tryResolveMorGeo( + input: MorAddressInput, + rows: MorLocationTuple[] = MOR_LOCATIONS, +): MorGeoCodes | null { + try { + return resolveMorGeo(input, rows); + } catch { + return null; + } +} diff --git a/apps/edr-freight-api/src/config/mor-locations.data.ts b/apps/edr-freight-api/src/config/mor-locations.data.ts new file mode 100644 index 000000000..b3b49c843 --- /dev/null +++ b/apps/edr-freight-api/src/config/mor-locations.data.ts @@ -0,0 +1,18 @@ +/** + * GENERATED FILE — do not hand-edit. + * + * MoR EIMS location master (`EIMS_COUNTRY_REGION_VW`), the Ministry's own geographic reference + * data. Regenerate from a supplied workbook with: + * + * pnpm --filter @edr/freight-api eims:import-locations + * + * Values are reproduced verbatim from the Ministry sheet — original spelling, original casing, + * original numbering, duplicates included. Nothing here is cleaned up or renumbered: this file is + * the traceable copy of the source. Spelling compatibility between EDR/e-Trade names and MoR names + * belongs in `mor-location.resolver.ts`'s normalization and alias layer, never here. + */ + +/** `[COUNTRY_NO, COUNTRY_NAME, PARISH_NO, PARISH_NAME, CITY_NO, CITY_NAME, LOCALITY_NO, LOCALITY_DESC]` */ +export type MorLocationTuple = [number, string, number, string, number, string, number, string]; + +export const MOR_LOCATIONS: MorLocationTuple[] = []; diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts index 75d39a500..a3103ca1c 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts @@ -60,11 +60,7 @@ const context = (over: Partial = {}): EimsMapperContext => ({ unitDefault: "PCS", incomeWithholdValue: 0, transactionWithholdValue: 0, - buyerCountryCode: "231", // test-only, not a confirmed real MoR code - buyerCountryCodes: {}, - buyerRegionCodes: { "Addis Ababa": "13" }, - buyerWeredaCodes: {}, - buyerCityCodes: {}, + buyerGeo: { Country: "70", Region: "6", City: "31", Wereda: "190" }, ...over, }); @@ -94,10 +90,9 @@ describe("toEimsInvoice", () => { const doc = toEimsInvoice(invoice(), seller, context()); expect(doc.BuyerDetails).toEqual({ - City: null, - // company.country is "Ethiopia" (the domestic default) — resolves to context's flat - // buyerCountryCode fallback, not null, per resolveCountryCode. - Country: "231", + // Resolved by the registration service before the counter was reserved; the mapper copies. + City: "31", + Country: "70", Email: "buyer@abc.et", HouseNumber: "NEW", IdNumber: null, @@ -105,11 +100,11 @@ describe("toEimsInvoice", () => { Tin: "0999930000", LegalName: "ABC Trading PLC", Phone: "0912345678", - Region: "13", + Region: "6", Zone: "SHA", Kebele: "03", VatNumber: "123475885858", - Wereda: "574", + Wereda: "190", }); }); @@ -284,108 +279,39 @@ describe("toEimsInvoice", () => { }); describe("toEimsInvoice — MoR field constraints", () => { - it("passes a buyer region through when it is already a MoR code", () => { + /** + * Geography is no longer resolved here. `resolveMorGeo` runs in the registration service, ahead + * of the counter reservation, and hands the mapper finished MoR codes — so what these cover is + * that the resolved values reach the right `BuyerDetails` fields untouched. The lookup rules + * themselves (hierarchy, aliases, ambiguity) are covered in `mor-location.resolver.spec.ts`. + */ + it("puts the resolved MoR codes on BuyerDetails, unmodified and as strings", () => { const doc = toEimsInvoice(invoice(), seller, context()); - expect(doc.BuyerDetails.Region).toBe("13"); + + expect(doc.BuyerDetails.Country).toBe("70"); + expect(doc.BuyerDetails.Region).toBe("6"); + expect(doc.BuyerDetails.City).toBe("31"); + expect(doc.BuyerDetails.Wereda).toBe("190"); + for (const field of ["Country", "Region", "City", "Wereda"] as const) { + expect(typeof doc.BuyerDetails[field]).toBe("string"); + } }); - it("maps a region name to its code, ignoring case and spacing", () => { - const doc = toEimsInvoice( - invoice({ company: { ...invoice().company!, region: " addis ababa " } }), - seller, - context({ buyerRegionCodes: { "Addis Ababa": "13" } }), - ); - expect(doc.BuyerDetails.Region).toBe("13"); - }); - - it("refuses to file a buyer whose region has no mapping", () => { - expect(() => - toEimsInvoice( - invoice({ company: { ...invoice().company!, region: "Somewhere Else" } }), - seller, - context(), - ), - ).toThrow(/not a MoR Region code and has no mapping/); - }); - - it("refuses a buyer with no region at all rather than guessing one", () => { - expect(() => - toEimsInvoice( - invoice({ company: { ...invoice().company!, region: null } }), - seller, - context(), - ), - ).toThrow(/buyer Region \(unset\)/); - }); - - it("passes a buyer wereda through when it is already a MoR code", () => { + it("never emits an Open Admin Data ETxx identifier as a location", () => { const doc = toEimsInvoice(invoice(), seller, context()); - expect(doc.BuyerDetails.Wereda).toBe("574"); + for (const field of ["Country", "Region", "City", "Wereda"] as const) { + expect(doc.BuyerDetails[field]).toMatch(/^[0-9]+$/); + } }); - it("maps a wereda name to its code", () => { + it("keeps BuyerDetails.Zone as the buyer's own zone name — MoR takes that one as prose", () => { const doc = toEimsInvoice( - invoice({ company: { ...invoice().company!, woreda: "Yeka" } }), + invoice({ company: { ...invoice().company!, zone: "Fafen" } }), seller, - context({ buyerWeredaCodes: { Yeka: "99" } }), + context(), ); - expect(doc.BuyerDetails.Wereda).toBe("99"); - }); - - it("refuses to file a buyer whose wereda has no mapping", () => { - expect(() => - toEimsInvoice( - invoice({ company: { ...invoice().company!, woreda: "Yeka" } }), - seller, - context({ buyerWeredaCodes: {} }), - ), - ).toThrow(/buyer Wereda "Yeka".*EIMS_BUYER_WEREDA_CODES/); - }); - - it("derives City from the buyer's zone via the city code map", () => { - const doc = toEimsInvoice( - invoice({ company: { ...invoice().company!, zone: "Kirkos" } }), - seller, - context({ buyerCityCodes: { Kirkos: "101" } }), - ); - expect(doc.BuyerDetails.City).toBe("101"); - }); - - it("leaves City null (not a throw) when the buyer's zone has no city mapping — City is optional", () => { - const doc = toEimsInvoice( - invoice({ company: { ...invoice().company!, zone: "Somewhere Else" } }), - seller, - context({ buyerCityCodes: {} }), - ); - expect(doc.BuyerDetails.City).toBeNull(); - }); - - it("maps a buyer country name to its code via the country code map", () => { - const doc = toEimsInvoice( - invoice({ company: { ...invoice().company!, country: "Djibouti" } }), - seller, - context({ buyerCountryCodes: { Djibouti: "071" } }), - ); - expect(doc.BuyerDetails.Country).toBe("071"); - }); - - it("falls back to the flat domestic country code only for Ethiopia, not any unmapped country", () => { - const doc = toEimsInvoice( - invoice({ company: { ...invoice().company!, country: "Ethiopia" } }), - seller, - context({ buyerCountryCode: "231", buyerCountryCodes: {} }), - ); - expect(doc.BuyerDetails.Country).toBe("231"); - }); - - it("refuses a genuinely foreign buyer country with no mapping — never silently files it as Ethiopia", () => { - expect(() => - toEimsInvoice( - invoice({ company: { ...invoice().company!, country: "Kenya" } }), - seller, - context({ buyerCountryCode: "231", buyerCountryCodes: {} }), - ), - ).toThrow(/buyer Country "Kenya".*EIMS_BUYER_COUNTRY_CODES/); + expect(doc.BuyerDetails.Zone).toBe("Fafen"); + expect(doc.BuyerDetails.City).toBe("31"); }); it("emits NatureOfSupplies lowercase, whatever case it was configured in", () => { diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts index c72e61c7a..c1ae4c514 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts @@ -15,6 +15,7 @@ * authoritative: they are passed through or overridable rather than validated against a fixed set. */ +import { MorGeoCodes } from "../../config/mor-location.resolver"; import { round2 } from "./invoice-settlement.util"; /** Only proven-required constant: the 400 SCHEMA ERROR sample rejects a payload without it. */ @@ -235,35 +236,15 @@ export interface EimsMapperContext { */ relatedDocument?: string | null; /** - * Domestic fallback only, applied when `company.country` is empty or "Ethiopia" and not already - * in `buyerCountryCodes` — see that field. Never applied to a genuinely foreign buyer. - */ - buyerCountryCode?: string | null; - /** Country name → MoR code. Format unconfirmed, so looked up by name only, not digit-validated. */ - buyerCountryCodes: Record; - /** - * Region name → MoR numeric code, for buyers whose stored region is free text. + * The buyer's MoR location codes — `Country`/`Region`/`City`/`Wereda`, already resolved from the + * Ministry's location master by `resolveMorGeo`. * - * `companies.region` holds names ("Addis Ababa") while MoR validates `BuyerDetails.Region` - * against `^[0-9]{1,3}$`. A stored value that is already a code passes through; anything else - * must be in this map or the mapping **fails locally** — sending a guessed region code onto a - * tax document is worse than refusing to file. + * Resolved by the caller, not here, and deliberately so: geographic resolution can fail (unknown + * or ambiguous address) and that failure must happen **before** an EIMS counter is reserved, so a + * bad company address never burns a sequence number. See `mor-location.resolver.ts` for why the + * lookup has to be hierarchical, and `EimsInvoiceRegistrationService` for where it runs. */ - buyerRegionCodes: Record; - /** - * Wereda name → MoR code, same shape as `buyerRegionCodes`. `companies.woreda` holds names - * ("Yeka") or codes inconsistently; unlike Region, MoR has never named a Wereda regex in an - * error, so this is precautionary rather than confirmed — but the fix is identical either way: - * fail locally on an unmapped name rather than file a guess. - */ - buyerWeredaCodes: Record; - /** - * Buyer *zone* name → MoR City code. `Company` has no dedicated city column; Zone is the - * closest match in EDR's own data. Unlike Region/Wereda, City is optional — MoR has already - * accepted a live filing with it null — so an unmapped zone resolves to null, it does not fail - * the mapping. - */ - buyerCityCodes: Record; + buyerGeo: MorGeoCodes; buyerIdType?: string | null; buyerIdNumber?: string | null; /** Required when the invoice currency is not ETB. */ @@ -273,14 +254,6 @@ export interface EimsMapperContext { formatDate?: (issuedAt: Date) => string; } -/** - * MoR's own constraint on `Region`: one to three digits, confirmed by its 400 SCHEMA ERROR. Reused - * as the pass-through test for `Wereda` too — every Wereda value MoR has actually shown us (seller - * "12"/"13", the collection's "574") fits the same shape, though MoR has not named a Wereda regex - * the way it named Region's. - */ -const LOCATION_CODE = /^[0-9]{1,3}$/; - /** * The only two values MoR accepts for `NatureOfSupplies`, lowercase. * @@ -309,87 +282,6 @@ export const formatEimsDate = (issuedAt: Date): string => * an unissued invoice, unresolved line tax, a line/total mismatch, or a non-ETB invoice with no * exchange rate. */ -/** - * A buyer's location value (Region, Wereda or City) as a MoR code: passed through when already - * numeric, otherwise looked up by name (case- and space-insensitive). - * - * Region/Wereda are required: an unmapped value throws — sending a guessed code onto a tax - * document is worse than refusing to file. City is optional (`required: false`, City's own - * caller) — MoR has already accepted a live filing with it null, so an unmapped zone resolves to - * null instead of blocking the invoice. - */ -function resolveLocationCode( - field: "Region" | "Wereda" | "City", - value: string | null | undefined, - codes: Record, - envVar: string, - invoiceNumber: string, - opts: { required?: boolean } = {}, -): string | null { - const raw = (value ?? "").trim(); - if (LOCATION_CODE.test(raw)) return raw; - - const key = raw.toLowerCase().replace(/\s+/g, " "); - const mapped = Object.entries(codes).find( - ([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key, - )?.[1]; - if (mapped && LOCATION_CODE.test(mapped)) return mapped; - - if (opts.required === false) return null; - - throw new Error( - `EIMS mapping: invoice ${invoiceNumber} has buyer ${field} ${raw ? `"${raw}"` : "(unset)"}, ` + - `which is not a MoR ${field} code and has no mapping. Add it to ${envVar}.`, - ); -} - -/** - * A buyer's `Country` as a MoR code: looked up by name in `codes` first; when unmapped, applies - * `domesticFallback` only if the stored country is empty or "Ethiopia" (the DB column's default). - * A genuinely foreign, unmapped country throws rather than silently filing as Ethiopia — same - * "fail locally, don't guess" rule as `resolveLocationCode`, but never digit-validated: MoR's - * Country code format is unconfirmed, unlike Region/Wereda's proven `^[0-9]{1,3}$`. - */ -function resolveCountryCode( - country: string | null | undefined, - codes: Record, - domesticFallback: string | null, - invoiceNumber: string, -): string | null { - const raw = (country ?? "").trim(); - const key = raw.toLowerCase().replace(/\s+/g, " "); - const mapped = Object.entries(codes).find( - ([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key, - )?.[1]; - if (mapped) return mapped; - - if ((!raw || key === "ethiopia") && domesticFallback) return domesticFallback; - - throw new Error( - `EIMS mapping: invoice ${invoiceNumber} has buyer Country "${raw || "(unset)"}", which has no ` + - "MoR country code mapping. Add it to EIMS_BUYER_COUNTRY_CODES.", - ); -} - -/** - * Same name-or-code resolution as `resolveLocationCode`, for a caller with no invoice to attach an - * error to and that must never throw — currently only `EimsSellerCacheService`, resolving - * e-Trade's region/zone/woreda *names* for EDR's own seller identity. Pass-through numeric code, - * name lookup, `undefined` on no match — the caller falls back to static config either way. - */ -export function resolveOptionalCode( - value: string | null | undefined, - codes: Record, -): string | undefined { - const raw = (value ?? "").trim(); - if (LOCATION_CODE.test(raw)) return raw; - const key = raw.toLowerCase().replace(/\s+/g, " "); - const mapped = Object.entries(codes).find( - ([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key, - )?.[1]; - return mapped && LOCATION_CODE.test(mapped) ? mapped : undefined; -} - export function toEimsInvoice( invoice: EimsMapperInvoice, seller: EimsSellerDetails, @@ -511,16 +403,11 @@ export function toEimsInvoice( return { BuyerDetails: { - // No dedicated city column on Company — Zone is the closest match; optional (see - // resolveLocationCode's City comment). - City: resolveLocationCode( - "City", - company.zone, - context.buyerCityCodes, - "EIMS_BUYER_CITY_CODES", - invoice.invoiceNumber, - { required: false }, - ), + // Country/Region/City/Wereda are MoR location codes resolved from the Ministry's own + // location master *before* this mapper ran, and before an EIMS counter was reserved — see + // EimsMapperContext.buyerGeo. `Zone` alongside them is the buyer's free-text zone name, + // which MoR takes as prose, not a code. + City: context.buyerGeo.City, Email: company.email ?? null, HouseNumber: company.houseNo ?? null, IdNumber: context.buyerIdNumber ?? null, @@ -528,29 +415,12 @@ export function toEimsInvoice( Tin: company.tin, LegalName: company.name, Phone: company.phone ?? null, - Region: resolveLocationCode( - "Region", - company.region, - context.buyerRegionCodes, - "EIMS_BUYER_REGION_CODES", - invoice.invoiceNumber, - ), - Country: resolveCountryCode( - company.country, - context.buyerCountryCodes, - context.buyerCountryCode ?? null, - invoice.invoiceNumber, - ), + Region: context.buyerGeo.Region, + Country: context.buyerGeo.Country, Zone: company.zone ?? null, Kebele: company.kebele ?? null, VatNumber: company.vatNumber ?? null, - Wereda: resolveLocationCode( - "Wereda", - company.woreda, - context.buyerWeredaCodes, - "EIMS_BUYER_WEREDA_CODES", - invoice.invoiceNumber, - ), + Wereda: context.buyerGeo.Wereda, }, DocumentDetails: { DocumentNumber: context.documentNumber, diff --git a/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.spec.ts index 459ee8f71..1cdca48d4 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.spec.ts @@ -36,9 +36,12 @@ const invoiceRow = (over: Partial = {}): Invoice => tin: "0999930000", vatNumber: "123475885858", phone: "0912345678", - region: "13", - zone: "SHA", - woreda: "574", + // A real MoR address — Ethiopia / SOMALI / FAAFAN ZONE / JIJIGA, which the Ministry master + // codes as 70 / 6 / 31 / 190. Spelled the way EDR and e-Trade actually store it, so the + // alias layer is exercised end to end rather than only in the resolver's own spec. + region: "Somali", + zone: "Fafen", + woreda: "Jigjiga", kebele: "03", houseNo: "NEW", country: "Ethiopia", diff --git a/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.ts index 4638d3555..875349b6a 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.ts @@ -7,6 +7,7 @@ import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialE import { EimsConfig } from "../../config/eims.config"; import { Invoice } from "../billing/entities/invoice.entity"; +import { MorGeoCodes, resolveMorGeo } from "../../config/mor-location.resolver"; import { EimsDocumentType, EimsMapperLine, toEimsInvoice } from "../billing/eims-invoice.mapper"; import { sendCompanyChannels } from "../notifications/notify-company.util"; import { NotificationsService } from "../notifications/notifications.service"; @@ -32,6 +33,8 @@ interface BulkReservation { invoice: Invoice & { lines: EimsMapperLine[] }; documentType: EimsDocumentType; relatedDocument: string | null; + /** Resolved before this reservation existed — see the `prepared` pass in `bulkRegister`. */ + buyerGeo: MorGeoCodes; invoiceCounter: number; documentNumber: string; previousIrn: string; @@ -123,7 +126,16 @@ export class EimsBulkRegistrationService { } relatedDocument = invoice.relatedInvoice.eimsIrn; } - return { invoice, documentType, relatedDocument }; + // Same rule as the single-invoice path: buyer geography is resolved from the MoR location + // master before reserveBulk touches a counter, so one bad company address fails the whole + // batch locally instead of burning a block of EIMS sequence numbers. + const buyerGeo = resolveMorGeo({ + country: invoice.company?.country, + region: invoice.company?.region, + zone: invoice.company?.zone, + woreda: invoice.company?.woreda, + }); + return { invoice, documentType, relatedDocument, buyerGeo }; }); if (prepared.length === 0) { @@ -141,6 +153,7 @@ export class EimsBulkRegistrationService { r.invoice, this.sellerCache.getSellerDetails(cfg), buildEimsContext(cfg, { + buyerGeo: r.buyerGeo, documentNumber: r.documentNumber, invoiceCounter: r.invoiceCounter, previousIrn: r.previousIrn, @@ -269,7 +282,12 @@ export class EimsBulkRegistrationService { /** TX1. Reserve a contiguous block of N counters, one per invoice, in the given order. */ private async reserveBulk( - prepared: Array<{ invoice: Invoice & { lines: EimsMapperLine[] }; documentType: EimsDocumentType; relatedDocument: string | null }>, + prepared: Array<{ + invoice: Invoice & { lines: EimsMapperLine[] }; + documentType: EimsDocumentType; + relatedDocument: string | null; + buyerGeo: MorGeoCodes; + }>, systemNumber: string, placeholder: string, ): Promise { @@ -302,7 +320,7 @@ export class EimsBulkRegistrationService { // Locked in the caller's given order — stable, avoids two concurrent bulk calls deadlocking // on the opposite lock order. - for (const { invoice, documentType, relatedDocument } of prepared) { + for (const { invoice, documentType, relatedDocument, buyerGeo } of prepared) { const locked = await this.lockInvoice(manager, invoice.id); const thisCounter = counter++; const thisDocNumber = String(docNumber++); @@ -322,6 +340,7 @@ export class EimsBulkRegistrationService { invoice: Object.assign(locked, { lines: invoice.lines }), documentType, relatedDocument, + buyerGeo, invoiceCounter: thisCounter, documentNumber: thisDocNumber, previousIrn: thisPreviousIrn, diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.spec.ts index 251f71cbd..135475b64 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.spec.ts @@ -66,7 +66,13 @@ describe("assertEimsInvoiceConfig — charge-type overrides", () => { }); describe("buildEimsContext — taxForLine", () => { - const input = { documentNumber: "24", invoiceCounter: 7, previousIrn: "", session: SESSION }; + const input = { + buyerGeo: { Country: "70", Region: "6", City: "31", Wereda: "190" }, + documentNumber: "24", + invoiceCounter: 7, + previousIrn: "", + session: SESSION, + }; const line = (chargeType: string) => ({ chargeType, quantity: 1, unitRate: 100, amount: 100 }); it("uses the per-chargeType override when one is configured", () => { diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts index 4c1c82c10..2fb83dc72 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts @@ -1,5 +1,6 @@ import { BadRequestException } from "@nestjs/common"; import { EimsConfig } from "../../config/eims.config"; +import { MorGeoCodes } from "../../config/mor-location.resolver"; import { EimsSessionContext } from "./eims-auth.service"; import { EimsMapperContext, @@ -149,6 +150,12 @@ export function buildEimsSeller(config: EimsConfig): EimsSellerDetails { } export interface EimsContextInput { + /** + * The buyer's MoR location codes, resolved from the Ministry location master by + * `resolveMorGeo` **before** the caller reserved an EIMS counter — see + * `EimsMapperContext.buyerGeo`. + */ + buyerGeo: MorGeoCodes; /** `DocumentDetails.DocumentNumber`. The caller decides its source. */ documentNumber: string; invoiceCounter: number; @@ -205,11 +212,7 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E unitDefault: invoice.unitDefault, incomeWithholdValue: invoice.incomeWithholdValue!, transactionWithholdValue: invoice.transactionWithholdValue!, - buyerCountryCode: invoice.buyerCountryCode, - buyerCountryCodes: invoice.buyerCountryCodes, - buyerRegionCodes: invoice.buyerRegionCodes, - buyerWeredaCodes: invoice.buyerWeredaCodes, - buyerCityCodes: invoice.buyerCityCodes, + buyerGeo: input.buyerGeo, // TEMPORARY — see EimsInvoiceConfig.buyerIdType. buyerIdType: invoice.buyerIdType, buyerIdNumber: invoice.buyerIdNumber, diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts index f560ff917..69a4d5ffd 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts @@ -13,6 +13,7 @@ import { EimsClientService } from "./eims-client.service"; import { EimsApiException, EimsConfigException } from "./eims.errors"; import { buildEimsSeller } from "./eims-invoice-context"; import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; +import { ETradeService } from "../companies/services/etrade.service"; import { EimsSellerCacheService } from "./eims-seller-cache.service"; import { EimsSystemState } from "./entities/eims-system-state.entity"; import { EimsInvoiceStatus } from "./eims-registration.types"; @@ -58,9 +59,12 @@ const invoiceRow = (over: Partial = {}): Invoice => vatNumber: "123475885858", phone: "0912345678", email: "buyer@abc.et", - region: "13", - zone: "SHA", - woreda: "574", + // A real MoR address — Ethiopia / SOMALI / FAAFAN ZONE / JIJIGA, which the Ministry master + // codes as 70 / 6 / 31 / 190. Spelled the way EDR and e-Trade actually store it, so the + // alias layer is exercised end to end rather than only in the resolver's own spec. + region: "Somali", + zone: "Fafen", + woreda: "Jigjiga", kebele: "03", houseNo: "NEW", country: "Ethiopia", @@ -502,21 +506,23 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => { }); }); - it("a mapper failure after reservation (e.g. unmapped buyer country) also releases the reservation", async () => { + it("a mapper failure after reservation still releases the reservation", async () => { // Regression: toEimsInvoice/buildEimsContext used to sit outside the try/catch that calls // settleFailure — a throw here left the reservation permanently orphaned (a real live incident: // 500 on register, then every subsequent attempt 409'd "already in flight" until manually // resolved). This never reaches postSigned at all — the mapper throws before submit() is called. - const db = new FakeDb([ - invoiceRow({ company: { ...invoiceRow().company, country: "France" } as never }), - ]); + // + // The trigger used to be an unmapped buyer country. That can no longer get this far: geography + // is resolved before the reservation now (see the test below). A line/total mismatch is a + // mapper-only failure that still reaches this point. + const db = new FakeDb([invoiceRow({ totalAmount: 999999 })]); const postSigned = jest.fn(); // The mapper throws a plain Error (it's a pure function, not a NestJS layer) — that's the // point: settleFailure must treat *any* non-EimsApiException as pre-wire, not just its own // known exception types. await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow( - /no MoR country code mapping/, + /lines sum to/, ); expect(postSigned).not.toHaveBeenCalled(); @@ -532,6 +538,69 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => { }); }); + it("an unmappable buyer address fails before a counter is ever reserved", async () => { + // The whole point of resolving geography ahead of reserve(): a company-record problem is a + // local data problem, and it must not cost an EIMS sequence number. Nothing about the invoice + // or the system state may change. + const db = new FakeDb([ + invoiceRow({ company: { ...invoiceRow().company, woreda: "Nowhere" } as never }), + ]); + const before = { ...db.state }; + const postSigned = jest.fn(); + + await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow( + /no MoR LOCALITY_DESC match/, + ); + + expect(postSigned).not.toHaveBeenCalled(); + expect(db.state).toMatchObject({ + nextInvoiceCounter: before.nextInvoiceCounter, + nextDocumentNumber: before.nextDocumentNumber, + inFlightInvoiceId: null, + }); + expect(db.invoices.get(INVOICE_ID)).toMatchObject({ + eimsStatus: EimsInvoiceStatus.NotSubmitted, + eimsInvoiceCounter: null, + }); + }); + + it("files the buyer's MoR codes, resolved from the location master with no e-Trade call", async () => { + const db = new FakeDb([invoiceRow()]); + const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "IRN-1" } }); + + // The real seller cache, wired to an e-Trade mock that must never be reached: registration + // reads the company row EDR already stored, so filing stays deterministic and independent of + // e-Trade's availability. `refresh()` is deliberately not called — the cache stays empty and + // the seller falls back to static config, exactly as it does on a cold process. + const cfg = config(); + const resolveCompanyData = jest.fn(); + const sellerCache = new EimsSellerCacheService( + { resolveCompanyData, extractRegistrationData: jest.fn() } as unknown as ETradeService, + { get: () => cfg } as unknown as ConfigService, + ); + + const service = new EimsInvoiceRegistrationService( + db.asDataSource(), + { get: () => cfg } as unknown as ConfigService, + { postSigned, postBearer: jest.fn() } as unknown as EimsClientService, + { getSessionContext: jest.fn().mockResolvedValue(SESSION) } as unknown as EimsAuthService, + { notify: jest.fn().mockResolvedValue(undefined) } as unknown as NotificationInboxService, + { directSend: jest.fn().mockResolvedValue(undefined) } as unknown as NotificationsService, + sellerCache, + ); + + await service.registerInvoiceWithEims(INVOICE_ID); + + const [, body] = postSigned.mock.calls[0]; + expect(body.BuyerDetails).toMatchObject({ + Country: "70", + Region: "6", + City: "31", + Wereda: "190", + }); + expect(resolveCompanyData).not.toHaveBeenCalled(); + }); + it("treats a success response with no IRN as a failed registration", async () => { const db = new FakeDb([invoiceRow()]); const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "" } }); diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts index a823e5ddf..d5c987779 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts @@ -29,6 +29,7 @@ import { EimsClientService } from "./eims-client.service"; import { EimsApiException, EimsConfigException } from "./eims.errors"; import { EimsSellerCacheService } from "./eims-seller-cache.service"; import { EimsSystemState } from "./entities/eims-system-state.entity"; +import { resolveMorGeo } from "../../config/mor-location.resolver"; import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context"; import { EimsInvoiceError, @@ -116,6 +117,17 @@ export class EimsInvoiceRegistrationService { relatedDocument = invoice.relatedInvoice.eimsIrn; } + // Buyer geography is resolved from the MoR location master *here*, ahead of the reservation: + // an unknown or ambiguous company address is a local data problem, and failing it after + // reserving would consume an EIMS sequence number for an invoice that was never filable. It + // needs no network access, so there is no reason for it to sit behind the login either. + const buyerGeo = resolveMorGeo({ + country: invoice.company?.country, + region: invoice.company?.region, + zone: invoice.company?.zone, + woreda: invoice.company?.woreda, + }); + // Authenticate before reserving: the source system comes from the token, and the state row is // keyed by it. A login failure here costs nothing — no counter has been consumed yet. const session = await this.auth.getSessionContext(); @@ -135,6 +147,7 @@ export class EimsInvoiceRegistrationService { invoice, this.sellerCache.getSellerDetails(cfg), buildEimsContext(cfg, { + buyerGeo, // Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber // against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy. documentNumber: reservation.documentNumber, diff --git a/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.spec.ts index 7b4ff1b3f..2c0e50b78 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.spec.ts @@ -5,11 +5,13 @@ import { ETradeService } from "../companies/services/etrade.service"; import { eimsConfig, eimsInvoiceConfig } from "./eims-test-fixtures"; import { EimsSellerCacheService } from "./eims-seller-cache.service"; +// A real MoR address (PARISH_NO 13 / CITY_NO 78 / LOCALITY_NO 1100) — the resolver now works off +// the Ministry's own hierarchy, so a made-up address would simply not resolve. const registrationData = (over: Record = {}) => ({ companyName: "Ethio-Djibouti Railway PLC (eTrade)", region: "Addis Ababa", zone: "Bole", - woreda: "Yeka", + woreda: "Woreda 1", mobilePhone: "0911000000", regularPhone: "", ...over, @@ -24,16 +26,10 @@ const build = (cfg: EimsConfig = eimsConfig()) => { return { service, resolveCompanyData, extractRegistrationData, cfg }; }; -const CODES = { - buyerRegionCodes: { "Addis Ababa": "13" }, - buyerWeredaCodes: { Yeka: "99" }, - buyerCityCodes: { Bole: "101" }, -}; - describe("EimsSellerCacheService.getSellerDetails", () => { it("static config wins over a conflicting e-Trade value", async () => { const cfg = eimsConfig({ - invoice: eimsInvoiceConfig({ sellerLegalName: "Ethio-Djibouti Railway S.C.", ...CODES }), + invoice: eimsInvoiceConfig({ sellerLegalName: "Ethio-Djibouti Railway S.C." }), }); const { service, resolveCompanyData } = build(cfg); resolveCompanyData.mockResolvedValue({ @@ -56,7 +52,6 @@ describe("EimsSellerCacheService.getSellerDetails", () => { sellerRegion: "", sellerWereda: "", sellerCity: null, - ...CODES, }), }); const { service, resolveCompanyData } = build(cfg); @@ -67,13 +62,32 @@ describe("EimsSellerCacheService.getSellerDetails", () => { expect(seller.LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)"); expect(seller.Region).toBe("13"); - expect(seller.Wereda).toBe("99"); + expect(seller.City).toBe("78"); + expect(seller.Wereda).toBe("1100"); + }); + + it("leaves the static seller values alone when MoR does not list the e-Trade address", async () => { + // e-Trade's free text does not always correspond to a MoR row (here "Yeka" is a MoR *City* + // under ADDIS ABABA, not a locality under BOLE). That must degrade to the static config, which + // MoR has already cleared under rule 7017 — never throw, and never file a guessed code. + const cfg = eimsConfig({ + invoice: eimsInvoiceConfig({ sellerRegion: "1", sellerWereda: "13", sellerCity: "101" }), + }); + const { service, resolveCompanyData, extractRegistrationData } = build(cfg); + extractRegistrationData.mockReturnValue(registrationData({ woreda: "Yeka" })); + resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} }); + + await expect(service.refresh()).resolves.toBeUndefined(); + const seller = service.getSellerDetails(cfg); + + expect(seller.Region).toBe("1"); + expect(seller.Wereda).toBe("13"); expect(seller.City).toBe("101"); }); it("VatNumber and Email are always the static value, never touched by e-Trade", async () => { const cfg = eimsConfig({ - invoice: eimsInvoiceConfig({ sellerVatNumber: "0000000000", sellerEmail: "finance@example.et", ...CODES }), + invoice: eimsInvoiceConfig({ sellerVatNumber: "0000000000", sellerEmail: "finance@example.et" }), }); const { service, resolveCompanyData } = build(cfg); resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} }); @@ -108,7 +122,7 @@ describe("EimsSellerCacheService.getSellerDetails", () => { describe("EimsSellerCacheService.refresh", () => { it("keeps the previous snapshot when a refresh fails", async () => { - const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) }); + const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "" }) }); const { service, resolveCompanyData } = build(cfg); resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} }); await service.refresh(); @@ -123,7 +137,7 @@ describe("EimsSellerCacheService.refresh", () => { it("keeps the previous snapshot on timeout, without waiting for the slow request", async () => { jest.useFakeTimers(); try { - const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) }); + const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "" }) }); const { service, resolveCompanyData } = build(cfg); resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} }); await service.refresh(); diff --git a/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts index a8d242929..be3e3aa8e 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts @@ -3,7 +3,8 @@ import { ConfigService } from "@nestjs/config"; import { EimsConfig } from "../../config/eims.config"; import { ETradeService } from "../companies/services/etrade.service"; -import { EimsSellerDetails, resolveOptionalCode } from "../billing/eims-invoice.mapper"; +import { tryResolveMorGeo } from "../../config/mor-location.resolver"; +import { EimsSellerDetails } from "../billing/eims-invoice.mapper"; import { buildEimsSeller } from "./eims-invoice-context"; const has = (value: string | null | undefined): value is string => Boolean(value && value.trim()); @@ -104,17 +105,24 @@ export class EimsSellerCacheService implements OnModuleInit { ); if (!businessInfo) return; // no licence on file yet — keep the previous snapshot const data = this.etrade.extractRegistrationData(businessInfo, companyInfo); - const codes = cfg.invoice; + // e-Trade returns region/zone/woreda as names ("Addis Ababa", "Bole") — resolved through the + // same MoR location master the buyer side uses, since the geography is objective, not + // buyer-specific. `tryResolveMorGeo` never throws: an address MoR does not list simply + // leaves these fields to getSellerDetails' static-config fallback, which is authoritative + // anyway (see the class comment — MoR has already cleared the static seller values under + // rule 7017, so nothing here may override one). e-Trade carries no country field; the + // resolver reads a blank country as domestic, which is correct for EDR's own registration. + const geo = tryResolveMorGeo({ + region: data.region, + zone: data.zone, + woreda: data.woreda, + }); this.cached = { LegalName: data.companyName || undefined, Phone: data.mobilePhone || data.regularPhone || undefined, - // e-Trade returns region/zone/woreda as names ("Addis Ababa", "Bole") — resolved via the - // same buyer code maps, since the geography is objective, not buyer-specific, despite the - // env var's "BUYER_" prefix. Never throws: an unmapped name just leaves that field to - // getSellerDetails' static-config fallback. - Region: resolveOptionalCode(data.region, codes.buyerRegionCodes), - Wereda: resolveOptionalCode(data.woreda, codes.buyerWeredaCodes), - City: resolveOptionalCode(data.zone, codes.buyerCityCodes), + Region: geo?.Region, + Wereda: geo?.Wereda, + City: geo?.City, }; } catch (err) { this.logger.warn( diff --git a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts index a55951db4..f85459e9e 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts @@ -32,11 +32,6 @@ export const eimsInvoiceConfig = (over: Partial = {}): EimsIn paymentMode: "CASH", paymentTerm: "IMMIDIATE", unitDefault: "PCS", - buyerCountryCode: null, - buyerCountryCodes: { Ethiopia: "231" }, // test-only, not a confirmed real MoR code - buyerRegionCodes: { "Addis Ababa": "13" }, - buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code - buyerCityCodes: { Kirkos: "101" }, // test-only, not a confirmed real MoR code taxCodeByChargeType: {}, taxRateByChargeType: {}, exciseByChargeType: {}, diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts index 21e0dd9a0..8cdc83853 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts @@ -2,6 +2,8 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BookingsModule } from '../bookings/bookings.module'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; +import { NotificationsModule } from '../notifications/notifications.module'; import { WarehousesModule } from '../warehouses/warehouses.module'; import { DjiboutiIncident } from './entities/djibouti-incident.entity'; import { EmptyContainerReturn } from './entities/empty-container-return.entity'; @@ -18,9 +20,12 @@ import { ImportOperationsService } from './import-operations.service'; ]), // WarehouseReleaseDocumentService (the shared PDF renderer) for the // equipment interchange receipt; BookingsModule for the customer - // ownership check on that same route. + // ownership check on that same route; the notification modules to tell + // the customer their receipt is ready at handover. WarehousesModule, BookingsModule, + NotificationInboxModule, + NotificationsModule, ], controllers: [ImportOperationsController], providers: [ImportOperationsService], diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts index ccec40139..be94e3f32 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts @@ -1,9 +1,13 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; +import { NotificationAudience, NotificationType } from '@edr/types'; import { LogoSettingsService } from '../logo-settings/logo-settings.service'; import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { sendCompanyChannels } from '../notifications/notify-company.util'; import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service'; import { CreateDjiboutiIncidentDto, @@ -35,6 +39,8 @@ const DAMAGE_INCIDENTS: DjiboutiIncidentType[] = [ @Injectable() export class ImportOperationsService { + private readonly logger = new Logger(ImportOperationsService.name); + constructor( @InjectRepository(DjiboutiIncident) private readonly incidents: Repository, @@ -44,6 +50,8 @@ export class ImportOperationsService { private readonly emptyReturns: Repository, private readonly pdfDocuments: WarehouseReleaseDocumentService, private readonly logoSettings: LogoSettingsService, + private readonly inbox: NotificationInboxService, + private readonly notifications: NotificationsService, ) {} listIncidents(bookingId?: string) { @@ -161,7 +169,7 @@ export class ImportOperationsService { async createEmptyReturn(dto: CreateEmptyContainerReturnDto) { const returnDate = dto.returnDate ? new Date(dto.returnDate) : new Date(); - return this.emptyReturns.save( + const saved = await this.emptyReturns.save( this.emptyReturns.create({ containerNumber: dto.containerNumber, bookingId: dto.bookingId ?? null, @@ -180,6 +188,15 @@ export class ImportOperationsService { ], }), ); + + // RETURNED is the physical interchange itself — the customer's/trucker's + // custody of the box ends here, EDR's begins. The receipt exists from this + // point on, so tell the customer now, not at some later internal status. + // Standalone returns (no booking) have no company to notify. + if (saved.bookingId) { + await this.notifyEquipmentInterchangeReady(saved); + } + return saved; } /** @@ -257,6 +274,34 @@ export class ImportOperationsService { return this.emptyReturns.findOneOrFail({ where: { id } }); } + private async notifyEquipmentInterchangeReady(row: EmptyContainerReturn): Promise { + try { + const [booking]: Array<{ companyId: string | null; reference: string }> = + await this.emptyReturns.manager.query( + `SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [row.bookingId], + ); + if (!booking?.companyId) return; + const body = `Container ${row.containerNumber} was handed over${ + row.facility ? ` at ${row.facility}` : '' + }. Your equipment interchange receipt for booking ${booking.reference} is ready to download from the portal.`; + await this.inbox.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.DOCUMENT_ACTION, + title: 'Equipment interchange receipt ready', + body, + link: `/bookings/${row.bookingId}`, + data: { bookingId: row.bookingId, emptyContainerReturnId: row.id }, + }); + await sendCompanyChannels(this.emptyReturns.manager.connection, this.notifications, booking.companyId, body); + } catch (err) { + this.logger.warn( + `Failed to notify equipment interchange ready for return ${row.id}: ${(err as Error).message}`, + ); + } + } + async getEmptyReturnOrThrow(id: string): Promise { const row = await this.emptyReturns.findOne({ where: { id } }); if (!row) { diff --git a/apps/edr-freight-api/src/scripts/import-mor-locations.ts b/apps/edr-freight-api/src/scripts/import-mor-locations.ts new file mode 100644 index 000000000..f45c81556 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/import-mor-locations.ts @@ -0,0 +1,278 @@ +/** + * Converts a Ministry of Revenues EIMS location workbook into `src/config/mor-locations.data.ts`. + * + * pnpm --filter @edr/freight-api eims:import-locations [--sheet SHEET_NAME] + * + * Exists so a future MoR workbook replaces the dataset by rerunning one command and reviewing the + * diff, instead of anyone hand-editing a thousand rows of tax reference data. Production never + * parses the workbook: the committed TypeScript is what ships. + * + * The generated file reproduces the Ministry's own labels and IDs verbatim. This script validates + * and reports; it does not correct. Spelling compatibility with EDR/e-Trade names lives in + * `mor-location.resolver.ts`, so the dataset stays traceable back to the source sheet. + */ +import { writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { Workbook } from "exceljs"; + +const DEFAULT_SHEET = "EIMS_COUNTRY_REGION_VW"; +const OUTPUT = resolve(__dirname, "../config/mor-locations.data.ts"); + +const COLUMNS = [ + "COUNTRY_NO", + "COUNTRY_NAME", + "PARISH_NO", + "PARISH_NAME", + "CITY_NO", + "CITY_NAME", + "LOCALITY_NO", + "LOCALITY_DESC", +] as const; + +type Column = (typeof COLUMNS)[number]; +type Row = [number, string, number, string, number, string, number, string]; + +/** Header cells arrive with stray casing, spaces and non-breaking spaces; compare on this form. */ +const headerKey = (value: unknown): string => + String(value ?? "") + .toUpperCase() + .replace(/[^A-Z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + +/** + * Cell text with only the transport-layer damage removed (Excel's non-breaking spaces, and the + * CR/LF a wrapped cell leaves behind). Deliberately keeps the Ministry's own leading/trailing + * spaces and spelling — the resolver normalizes at comparison time, the dataset stays as supplied. + */ +const cellText = (value: unknown): string => { + if (value === null || value === undefined) return ""; + const raw = + typeof value === "object" && "text" in (value as object) + ? String((value as { text: unknown }).text ?? "") + : String(value); + return raw.replace(/\u00a0/g, " ").replace(/\r?\n/g, " "); +}; + +const cellNumber = (value: unknown): number | null => { + const text = cellText(value).trim(); + if (!/^[0-9]+$/.test(text)) return null; + return Number(text); +}; + +async function main(): Promise { + const args = process.argv.slice(2); + const sheetFlag = args.indexOf("--sheet"); + const sheetName = sheetFlag >= 0 ? args[sheetFlag + 1] : DEFAULT_SHEET; + const workbookPath = args.find((arg, i) => !arg.startsWith("--") && i !== sheetFlag + 1); + + if (!workbookPath) { + throw new Error( + "Usage: eims:import-locations [--sheet SHEET_NAME]\n" + + `Defaults to sheet "${DEFAULT_SHEET}".`, + ); + } + + const workbook = new Workbook(); + await workbook.xlsx.readFile(resolve(process.cwd(), workbookPath)); + + const sheet = workbook.getWorksheet(sheetName); + if (!sheet) { + const available = workbook.worksheets.map((w) => w.name).join(", "); + throw new Error(`Sheet "${sheetName}" not found. Sheets in this workbook: ${available}`); + } + + // The header is not guaranteed to be row 1 — find the first row carrying every required column. + let headerRow = 0; + let columnIndex: Partial> = {}; + for (let r = 1; r <= Math.min(sheet.rowCount, 20); r++) { + const found: Partial> = {}; + sheet.getRow(r).eachCell({ includeEmpty: false }, (cell, colNumber) => { + const key = headerKey(cell.value) as Column; + if (COLUMNS.includes(key) && found[key] === undefined) found[key] = colNumber; + }); + if (COLUMNS.every((c) => found[c] !== undefined)) { + headerRow = r; + columnIndex = found; + break; + } + } + if (headerRow === 0) { + throw new Error( + `Sheet "${sheetName}" has no header row containing all required columns: ${COLUMNS.join(", ")}`, + ); + } + + const rows: Row[] = []; + const problems: string[] = []; + + for (let r = headerRow + 1; r <= sheet.rowCount; r++) { + const sheetRow = sheet.getRow(r); + const at = (column: Column): unknown => sheetRow.getCell(columnIndex[column]!).value; + + // A sheet exported from a view is usually padded with blank rows at the end; skip silently. + if (COLUMNS.every((c) => cellText(at(c)).trim() === "")) continue; + + const countryNo = cellNumber(at("COUNTRY_NO")); + const parishNo = cellNumber(at("PARISH_NO")); + const cityNo = cellNumber(at("CITY_NO")); + const localityNo = cellNumber(at("LOCALITY_NO")); + const countryName = cellText(at("COUNTRY_NAME")); + const parishName = cellText(at("PARISH_NAME")); + const cityName = cellText(at("CITY_NAME")); + const localityDesc = cellText(at("LOCALITY_DESC")); + + const missingIds = ( + [ + ["COUNTRY_NO", countryNo], + ["PARISH_NO", parishNo], + ["CITY_NO", cityNo], + ["LOCALITY_NO", localityNo], + ] as const + ) + .filter(([, value]) => value === null) + .map(([name]) => name); + const blankNames = ( + [ + ["COUNTRY_NAME", countryName], + ["PARISH_NAME", parishName], + ["CITY_NAME", cityName], + ["LOCALITY_DESC", localityDesc], + ] as const + ) + .filter(([, value]) => value.trim() === "") + .map(([name]) => name); + + if (missingIds.length > 0 || blankNames.length > 0) { + problems.push( + `row ${r}: ${[ + missingIds.length ? `non-numeric/missing ${missingIds.join(", ")}` : "", + blankNames.length ? `blank ${blankNames.join(", ")}` : "", + ] + .filter(Boolean) + .join("; ")}`, + ); + continue; + } + + rows.push([ + countryNo!, + countryName, + parishNo!, + parishName, + cityNo!, + cityName, + localityNo!, + localityDesc, + ]); + } + + if (problems.length > 0) { + throw new Error( + `Sheet "${sheetName}" has ${problems.length} unusable row(s); nothing was written:\n ` + + problems.slice(0, 25).join("\n ") + + (problems.length > 25 ? `\n ... and ${problems.length - 25} more` : ""), + ); + } + if (rows.length === 0) { + throw new Error(`Sheet "${sheetName}" has a valid header but no data rows.`); + } + + // Exact duplicates carry no information and only inflate the file — collapsed here, and the + // count reported, so the collapse is a stated decision rather than a silent one. IDs are never + // touched: only whole identical rows are dropped. + const seen = new Map(); + for (const row of rows) { + const key = JSON.stringify(row); + if (!seen.has(key)) seen.set(key, row); + } + const unique = [...seen.values()]; + const duplicates = rows.length - unique.length; + + // Deterministic output: same workbook in, byte-identical file out, so a regeneration diff shows + // only what the Ministry actually changed. + unique.sort( + (a, b) => + a[0] - b[0] || + a[2] - b[2] || + a[4] - b[4] || + a[6] - b[6] || + a[7].localeCompare(b[7]) || + a[5].localeCompare(b[5]), + ); + + // A name that resolves to two different codes under the same parent cannot be resolved by any + // amount of normalization — the resolver refuses it as ambiguous at filing time rather than + // picking one. Reported here so it can be raised with MoR instead of surfacing on a live invoice. + const byPath = new Map>(); + const collide = (level: string, path: string, name: string, no: number): void => { + const key = [level, path, name.trim().toUpperCase().replace(/\s+/g, " ")].join(" | "); + if (!byPath.has(key)) byPath.set(key, new Set()); + byPath.get(key)!.add(no); + }; + for (const [cNo, cName, pNo, pName, tNo, tName, lNo, lName] of unique) { + collide("COUNTRY_NAME", "", cName, cNo); + collide("PARISH_NAME", String(cNo), pName, pNo); + collide("CITY_NAME", `${cNo}/${pNo}`, tName, tNo); + collide("LOCALITY_DESC", `${cNo}/${pNo}/${tNo}`, lName, lNo); + } + const conflicts = [...byPath.entries()] + .filter(([, codes]) => codes.size > 1) + .map(([key, codes]) => { + const [level, path, name] = key.split(" | "); + const where = path ? ` under ${path}` : ""; + return ` ${level} "${name}"${where} -> codes ${[...codes].sort((a, b) => a - b).join(", ")}`; + }) + .sort(); + + const header = `/** + * GENERATED FILE — do not hand-edit. + * + * MoR EIMS location master (\`${sheetName}\`), 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[] = [ +`; + const body = unique + .map( + ([cNo, cName, pNo, pName, tNo, tName, lNo, lName]) => + ` [${cNo}, ${JSON.stringify(cName)}, ${pNo}, ${JSON.stringify(pName)}, ${tNo}, ` + + `${JSON.stringify(tName)}, ${lNo}, ${JSON.stringify(lName)}],`, + ) + .join("\n"); + + writeFileSync(OUTPUT, `${header}${body}\n];\n`, "utf8"); + + const countries = new Set(unique.map((r) => r[0])).size; + const regions = new Set(unique.map((r) => `${r[0]}/${r[2]}`)).size; + const zones = new Set(unique.map((r) => `${r[0]}/${r[2]}/${r[4]}`)).size; + + console.log(`Wrote ${OUTPUT}`); + console.log( + ` ${unique.length} rows - ${countries} countries, ${regions} regions, ${zones} zones` + + (duplicates > 0 ? `; collapsed ${duplicates} exact duplicate row(s)` : ""), + ); + if (conflicts.length > 0) { + console.log( + ` ${conflicts.length} same-hierarchy name conflict(s) - these resolve to an ambiguity ` + + "error at filing time, never to a guess:", + ); + console.log(conflicts.join("\n")); + } +} + +main().catch((err: Error) => { + console.error(err.message); + process.exit(1); +}); diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 1fa8aa926..42cebe0b9 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -780,6 +780,8 @@ export const URL_CONSTANTS = { `/import-operations/empty-container-returns/${id}/status`, EMPTY_CONTAINER_RETURNS_LOAD_ON_TRAIN: "/import-operations/empty-container-returns/load-on-train", + EMPTY_CONTAINER_RETURN_DOCUMENT: (id: string) => + `/import-operations/empty-container-returns/${id}/document`, }, VEHICLES: { diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx index 685e7d2a3..12539a136 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx @@ -19,12 +19,14 @@ import { Select, Checkbox, } from "@mantine/core"; -import { ChevronDown, ChevronRight, History } from "lucide-react"; +import { ChevronDown, ChevronRight, FileText, History } from "lucide-react"; import { DataTable, type ColumnDef } from "@edr/ui-common"; import { PageContainer, PageHeader } from "@/components/page"; import ListControls from "@/components/common/ListControls"; import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter"; +import { extractDownloadErrorMessage } from "@/components/warehouses/options"; +import { openPdfBlob } from "@/components/warehouses/pdf"; import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart"; import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart"; import { useListControls, toDayString } from "@/hooks/useListControls"; @@ -103,6 +105,25 @@ export default function ContainerReturnsPage() { const [activeKey, setActiveKey] = useState(null); const [historyRow, setHistoryRow] = useState(null); const [allocateRow, setAllocateRow] = useState(null); + const [documentBusyId, setDocumentBusyId] = useState(null); + + const viewInterchangeDocument = async (ret: EmptyContainerReturn) => { + setDocumentBusyId(ret.id); + const pdfWindow = window.open("", "_blank"); + try { + const response = await importOperationsService.downloadEquipmentInterchangeDocument(ret.id); + openPdfBlob(response.data, `equipment-interchange-${ret.containerNumber}.pdf`, pdfWindow); + } catch (error) { + pdfWindow?.close(); + toast({ + variant: "destructive", + title: "Could not open interchange receipt", + description: await extractDownloadErrorMessage(error), + }); + } finally { + setDocumentBusyId(null); + } + }; const { data: unloadedQueue = [], isLoading: queueLoading } = useQuery({ queryKey: ["import-unloaded-queue"], @@ -416,6 +437,15 @@ export default function ContainerReturnsPage() { const nextStatus = RETURN_STATUS_ORDER[RETURN_STATUS_ORDER.indexOf(ret.status) + 1]; return ( + void viewInterchangeDocument(ret)} + loading={documentBusyId === ret.id} + title="View equipment interchange receipt" + > + + + client.get(URL_CONSTANTS.IMPORT_OPERATIONS.EMPTY_CONTAINER_RETURN_DOCUMENT(id), { + responseType: 'blob', + }), };