From 7946c1632747d4e72c354fc1d8cedd29af60184c Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 17 Aug 2026 14:05:47 +0000 Subject: [PATCH] feat(eims): bake in a starter table of Ethiopia region/zone/woreda codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EIMS_BUYER_REGION_CODES/WEREDA_CODES/CITY_CODES were hand-maintained, empty-by-default env vars — every buyer from a not-yet-seen area crashed filing until someone found the MoR code and redeployed. Happened three times in one afternoon (2026-08-17): Somali region, Fafan zone, Jigjiga woreda, even the Ethiopia country code were all unset on the triaplc.com deployment. Ethiopia's administrative divisions are fixed reference data, not buyer-specific config. Added ethiopia-geo-codes.ts, a static table (source: ethiopia_administrative_hierarchy_master.csv, supplied 2026-08-17 — a representative sample, not all ~1000 real woredas) merged in as the fallback under the existing env vars, which still win on a name collision — for a quick correction without a redeploy, or a buyer profile spelled differently than the table (already hit: DB has zone "Fafen", official spelling "Fafan"). Known limitation, documented in the file: zone/woreda names aren't always unique nationwide (e.g. "North Shewa" is both an Amhara and an Oromia zone) and Company stores region/zone/woreda as three independent strings with no parent linkage, so a flat name map can't always disambiguate. Only affects the optional City field — never blocks filing, unlike Region/Wereda. --- .../src/config/eims.config.spec.ts | 44 ++++++ .../edr-freight-api/src/config/eims.config.ts | 10 +- .../src/config/ethiopia-geo-codes.ts | 139 ++++++++++++++++++ 3 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 apps/edr-freight-api/src/config/ethiopia-geo-codes.ts 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 a6aa3895d..60ee33e1b 100644 --- a/apps/edr-freight-api/src/config/eims.config.spec.ts +++ b/apps/edr-freight-api/src/config/eims.config.spec.ts @@ -70,3 +70,47 @@ 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 + }, + ); + }); +}); diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index 9d99ae455..e5530eaf2 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -1,5 +1,7 @@ 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. * @@ -258,9 +260,11 @@ export default registerAs("eims", (): EimsConfig => { unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "", buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null, buyerCountryCodes: parseCodeMap(process.env.EIMS_BUYER_COUNTRY_CODES), - buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES), - buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES), - buyerCityCodes: parseCodeMap(process.env.EIMS_BUYER_CITY_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 new file mode 100644 index 000000000..f7257e045 --- /dev/null +++ b/apps/edr-freight-api/src/config/ethiopia-geo-codes.ts @@ -0,0 +1,139 @@ +/** + * 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]]);