feat(eims): resolve buyer geography from the MoR location master

BuyerDetails Country/Region/City/Wereda now resolve from the Ministry's own
EIMS_COUNTRY_REGION_VW master instead of the EIMS_BUYER_*_CODES env maps and
the ethiopia-geo-codes table. Both invented their codes and looked names up
globally, so KERSA/GORO/BABILE/BURE — each present in several zones with
different LOCALITY_NOs — could be filed against the wrong jurisdiction.

Resolution is hierarchical and refuses to guess: an unknown or ambiguous
address raises a local validation error naming the level that failed, and
never selects the first matching row. Spelling differences between EDR and
MoR live in a reviewed, parent-scoped alias layer; the dataset itself stays
verbatim so it remains traceable to the Ministry sheet.

Resolution now runs before the counter reservation in both the single and
bulk paths, so a bad company address no longer burns an EIMS sequence number.

Adds eims:import-locations to regenerate the dataset from a future workbook,
reporting duplicate rows and same-hierarchy code conflicts.
This commit is contained in:
Hagernesh
2026-08-24 04:23:47 +00:00
parent d285006114
commit c937290019
25 changed files with 1150 additions and 566 deletions

View File

@@ -70,62 +70,3 @@ describe("eims.config — private key / certificate resolution", () => {
);
});
});
describe("eims.config — baked-in Ethiopia region/zone/woreda codes", () => {
it("resolves a known region/wereda/zone with no env var set at all", () => {
withEnv(
{ ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" },
() => {
const cfg = eimsConfigFactory();
expect(cfg.invoice.buyerRegionCodes.Somali).toBe("05");
expect(cfg.invoice.buyerWeredaCodes["Jijiga Town"]).toBe("02");
expect(cfg.invoice.buyerCityCodes.Fafan).toBe("01");
},
);
});
it("an env var entry overrides the baked-in code for the same name", () => {
withEnv(
{
...REQUIRED,
EIMS_PRIVATE_KEY: "x",
EIMS_CERTIFICATE_PATH: "/dev/null",
EIMS_BUYER_REGION_CODES: "Somali=99",
},
() => {
expect(eimsConfigFactory().invoice.buyerRegionCodes.Somali).toBe("99");
},
);
});
it("an env var still adds a name the baked-in table doesn't have (a spelling variant)", () => {
withEnv(
{
...REQUIRED,
EIMS_PRIVATE_KEY: "x",
EIMS_CERTIFICATE_PATH: "/dev/null",
EIMS_BUYER_CITY_CODES: "Fafen=01",
},
() => {
const codes = eimsConfigFactory().invoice.buyerCityCodes;
expect(codes.Fafen).toBe("01");
expect(codes.Fafan).toBe("01"); // baked-in entry still present alongside it
},
);
});
it("resolves the bare Addis Ababa sub-city name a buyer profile actually stores, not the CSV's example-woreda name", () => {
withEnv(
{ ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" },
() => {
const codes = eimsConfigFactory().invoice.buyerWeredaCodes;
expect(codes.Bole).toBe("01");
expect(codes.Arada).toBe("01");
expect(codes.Kirkos).toBe("01");
expect(codes.Yeka).toBe("01");
expect(codes["Nifas Silk Lafto"]).toBe("13");
expect(codes["Nefas Silk-Lafto"]).toBe("13");
},
);
});
});

View File

@@ -1,6 +1,5 @@
import { registerAs } from "@nestjs/config";
import { ETHIOPIA_REGION_CODES, ETHIOPIA_WOREDA_CODES, ETHIOPIA_ZONE_CODES } from "./ethiopia-geo-codes";
/**
* Ethiopian MoR EIMS e-invoicing gateway.
@@ -99,35 +98,6 @@ export interface EimsInvoiceConfig {
paymentMode: string;
paymentTerm: string;
unitDefault: string;
/**
* Domestic fallback only — used when the buyer's `Company.country` is empty or "Ethiopia" (the
* column's own default) and not already listed in `buyerCountryCodes`. A genuinely foreign
* buyer must be in `buyerCountryCodes` by name or the mapping fails locally; this value is never
* applied to them, so an unconfigured foreign country can't silently be filed as Ethiopia.
*/
buyerCountryCode: string | null;
/**
* Country name → MoR code, from `EIMS_BUYER_COUNTRY_CODES` ("Ethiopia=231,Djibouti=071"). Format
* unconfirmed (unlike Region/Wereda, MoR has never named a Country regex), so — unlike them —
* this is not validated against a fixed digit pattern, only looked up by name.
*/
buyerCountryCodes: Record<string, string>;
/**
* Buyer region name → MoR numeric code, from `EIMS_BUYER_REGION_CODES`
* ("Addis Ababa=13,Oromia=4"). A buyer whose region is neither a code nor in this map fails
* locally rather than being filed with a guessed one.
*/
buyerRegionCodes: Record<string, string>;
/** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */
buyerWeredaCodes: Record<string, string>;
/**
* Buyer *zone* name → MoR City code, from `EIMS_BUYER_CITY_CODES` ("Kirkos=101"). `Company` has
* no dedicated city column — Zone is the closest match in EDR's own data. Optional, unlike
* Region/Wereda: MoR has never required City on a live buyer (confirmed — filing already
* succeeds with it null), so an unmapped zone falls back to null rather than failing the
* mapping.
*/
buyerCityCodes: Record<string, string>;
/**
* Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` +
* `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to
@@ -258,13 +228,6 @@ export default registerAs("eims", (): EimsConfig => {
paymentMode: process.env.EIMS_PAYMENT_MODE ?? "",
paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "",
unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "",
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
buyerCountryCodes: parseCodeMap(process.env.EIMS_BUYER_COUNTRY_CODES),
// Baked-in Ethiopia reference table first, env var entries win on a name collision — lets a
// deployment override or add to it without a redeploy. See ethiopia-geo-codes.ts.
buyerRegionCodes: { ...ETHIOPIA_REGION_CODES, ...parseCodeMap(process.env.EIMS_BUYER_REGION_CODES) },
buyerWeredaCodes: { ...ETHIOPIA_WOREDA_CODES, ...parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES) },
buyerCityCodes: { ...ETHIOPIA_ZONE_CODES, ...parseCodeMap(process.env.EIMS_BUYER_CITY_CODES) },
taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE),
taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE),
exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE),

View File

@@ -1,160 +0,0 @@
/**
* MoR EIMS region/zone/woreda codes, by name — the baked-in fallback under
* `EIMS_BUYER_REGION_CODES`/`EIMS_BUYER_WEREDA_CODES`/`EIMS_BUYER_CITY_CODES` (zone is the closest
* match to EIMS's "City", per `eims-invoice.mapper.ts`).
*
* Before this existed, every buyer from a not-yet-seen region/zone/woreda crashed EIMS filing until
* someone hunted down the code and added it to an env var by hand — happened three times in one
* afternoon (2026-08-17: Somali region, Fafan zone, Jigjiga woreda, even the Ethiopia country code
* itself were all unset). Ethiopia's administrative divisions are fixed, known, reference data, not
* something that should be maintained reactively per buyer. Source: `ethiopia_administrative_
* hierarchy_master.csv`, supplied 2026-08-17 — NOT exhaustive (a representative sample per region,
* not all ~1000 real woredas), extend as new gaps surface.
*
* The env vars stay wired in ahead of this table (see `eims.config.ts`) — for a quick correction
* without a redeploy, or a name spelled differently in a buyer's profile than in this table (already
* hit live: DB has zone "Fafen", this table's official spelling is "Fafan" — same zone, matching is
* case/space-insensitive but not spelling-tolerant, so the env var override is still how that buyer
* actually resolves; this table mainly helps the *next* buyer whose profile spelling matches).
*
* ponytail: region names are unique nationwide (only ~15), safe as a flat map. Zone and woreda names
* are not always unique across different regions (e.g. "North Shewa" is both an Amhara zone and an
* Oromia zone, different codes) — `Company` stores region/zone/woreda as three independent strings,
* no parent linkage, so a flat name lookup can't disambiguate. First occurrence in the source data
* wins on a collision. Only affects the optional `City` field (zone) — never blocks filing, unlike
* Region/Wereda. A correct fix needs `Company` to store a linked hierarchy, not just three strings;
* out of scope here. Upgrade path: key this by `${region}/${zone}` once that linkage exists.
*/
const ROWS: Array<[region: string, zone: string, woreda: string, regionCode: string, zoneCode: string, woredaCode: string]> = [
["Tigray", "Western Tigray", "Humera", "01", "01", "01"],
["Tigray", "Western Tigray", "Kafta Humera", "01", "01", "02"],
["Tigray", "Western Tigray", "Tsegede", "01", "01", "03"],
["Tigray", "North Western Tigray", "Shire Endaselassie", "01", "02", "01"],
["Tigray", "North Western Tigray", "Sheraro", "01", "02", "02"],
["Tigray", "Central Tigray", "Axum", "01", "03", "01"],
["Tigray", "Central Tigray", "Adwa", "01", "03", "02"],
["Tigray", "Eastern Tigray", "Adigrat", "01", "04", "01"],
["Tigray", "Southern Tigray", "Maychew", "01", "05", "01"],
["Tigray", "Mekelle Special Zone", "Mekelle City", "01", "06", "01"],
["Afar", "Awusi Rasu (Zone 1)", "Asayita", "02", "01", "01"],
["Afar", "Awusi Rasu (Zone 1)", "Semera-Logiya", "02", "01", "02"],
["Afar", "Kilbet Rasu (Zone 2)", "Abala", "02", "02", "01"],
["Afar", "Gabi Rasu (Zone 3)", "Awash Fentale", "02", "03", "01"],
["Afar", "Fantena Rasu (Zone 4)", "Yalo", "02", "04", "01"],
["Afar", "Hari Rasu (Zone 5)", "Telalak", "02", "05", "01"],
["Amhara", "North Gondar", "Debark", "03", "01", "01"],
["Amhara", "South Gondar", "Debre Tabor", "03", "02", "01"],
["Amhara", "North Wollo", "Woldiya", "03", "03", "01"],
["Amhara", "South Wollo", "Dessie Town", "03", "04", "01"],
["Amhara", "North Shewa", "Debre Berhan", "03", "05", "01"],
["Amhara", "East Gojjam", "Debre Markos", "03", "06", "01"],
["Amhara", "West Gojjam", "Finote Selam", "03", "07", "01"],
["Amhara", "Wag Hemra", "Sekota", "03", "08", "01"],
["Amhara", "Awi", "Injibara", "03", "09", "01"],
["Amhara", "Oromia Special Zone", "Kemise", "03", "10", "01"],
["Amhara", "Bahir Dar Special Zone", "Bahir Dar City", "03", "11", "01"],
["Amhara", "Gondar Special Zone", "Gondar City", "03", "12", "01"],
["Oromia", "North Shewa", "Fiche", "04", "01", "01"],
["Oromia", "South West Shewa", "Waliso", "04", "02", "01"],
["Oromia", "East Shewa", "Adama Town", "04", "03", "01"],
["Oromia", "East Shewa", "Bishoftu Town", "04", "03", "02"],
["Oromia", "West Shewa", "Ambo", "04", "04", "01"],
["Oromia", "Arsi", "Asella", "04", "05", "01"],
["Oromia", "West Arsi", "Shashemene", "04", "06", "01"],
["Oromia", "Bale", "Robe", "04", "07", "01"],
["Oromia", "East Hararghe", "Harar Outskirts", "04", "08", "01"],
["Oromia", "West Hararghe", "Chiro", "04", "09", "01"],
["Oromia", "Jimma", "Jimma Town", "04", "10", "01"],
["Oromia", "Illubabor", "Mettu", "04", "11", "01"],
["Oromia", "Buno Bedele", "Bedele", "04", "12", "01"],
["Oromia", "Welega (West)", "Gimbi", "04", "13", "01"],
["Oromia", "Welega (East)", "Nekemte", "04", "14", "01"],
["Oromia", "Horo Guduru Welega", "Shambu", "04", "15", "01"],
["Oromia", "Kelam Welega", "Dembidolo", "04", "16", "01"],
["Oromia", "Borena", "Yabelo", "04", "17", "01"],
["Oromia", "Guji", "Negele Borana", "04", "18", "01"],
["Oromia", "West Guji", "Bule Hora", "04", "19", "01"],
["Oromia", "East Bale", "Ginir", "04", "20", "01"],
["Oromia", "Sheger City", "Sululta", "04", "21", "01"],
["Somali", "Fafan", "Jijiga Woreda", "05", "01", "01"],
["Somali", "Fafan", "Jijiga Town", "05", "01", "02"],
["Somali", "Fafan", "Awbare", "05", "01", "03"],
["Somali", "Sitti", "Shinile", "05", "02", "01"],
["Somali", "Erer", "Fiq", "05", "03", "01"],
["Somali", "Jarar", "Degehabur", "05", "04", "01"],
["Somali", "Nogob", "Segeg", "05", "05", "01"],
["Somali", "Korahe", "Kebridehar", "05", "06", "01"],
["Somali", "Shabelle", "Gode", "05", "07", "01"],
["Somali", "Afder", "Afder Woreda", "05", "08", "01"],
["Somali", "Liben", "Filtu", "05", "09", "01"],
["Somali", "Dhawa", "Mubarak", "05", "10", "01"],
["Somali", "Dollo", "Warder", "05", "11", "01"],
["Benishangul-Gumuz", "Asosa", "Asosa Woreda", "06", "01", "01"],
["Benishangul-Gumuz", "Kamasashi", "Kamasashi Woreda", "06", "02", "01"],
["Benishangul-Gumuz", "Metekel", "Gilgel Beles", "06", "03", "01"],
["Southern Ethiopia", "Wolayta", "Sodo Zuria", "07", "01", "01"],
["Southern Ethiopia", "Wolayta", "Sodo Town", "07", "01", "02"],
["Southern Ethiopia", "Gamo", "Arba Minch Town", "07", "02", "01"],
["Southern Ethiopia", "Gofa", "Sawla", "07", "03", "01"],
["Southern Ethiopia", "Konso", "Konso Woreda", "07", "04", "01"],
["Southern Ethiopia", "South Omo", "Jinka", "07", "05", "01"],
["Gambela", "Anywaa", "Gambela Zuria", "08", "01", "01"],
["Gambela", "Nuer", "Lare", "08", "02", "01"],
["Gambela", "Majang", "Metu Zuria part", "08", "03", "01"],
["Harari", "Harar Hundanee", "Amir Nur Woreda", "09", "01", "01"],
["Harari", "Harar Hundanee", "Abadir Woreda", "09", "01", "02"],
["Addis Ababa", "Bole Sub-City", "Bole Woreda 01", "10", "01", "01"],
["Addis Ababa", "Kirkos Sub-City", "Kirkos Woreda 01", "10", "02", "01"],
["Addis Ababa", "Nifas Silk Lafto", "NSL Woreda 13", "10", "03", "13"],
["Addis Ababa", "Yeka Sub-City", "Yeka Woreda 01", "10", "04", "01"],
["Addis Ababa", "Arada Sub-City", "Arada Woreda 01", "10", "05", "01"],
["Dire Dawa", "Dire Dawa Urban", "Melka Jebdu", "11", "01", "01"],
["Dire Dawa", "Dire Dawa Rural", "Gurgura", "11", "02", "01"],
["Sidama", "Hawassa City Admin", "Hayek Chereka", "12", "01", "01"],
["Sidama", "Sidama Zuria", "Yirgalem Town", "12", "02", "01"],
["Sidama", "Sidama Zuria", "Aleta Wendo", "12", "02", "02"],
["Southwest Ethiopia", "Keffa", "Bonga Town", "13", "01", "01"],
["Southwest Ethiopia", "Sheka", "Mappi Zuria", "13", "02", "01"],
["Southwest Ethiopia", "Bench Sheko", "Mizan Aman", "13", "03", "01"],
["Central Ethiopia", "Gurage", "Wolkite", "14", "01", "01"],
["Central Ethiopia", "Hadiya", "Hosaina", "14", "02", "01"],
["Central Ethiopia", "Silte", "Worabe", "14", "03", "01"],
["Gedeo State", "Gedeo Zone", "Dilla Zuria", "15", "01", "01"],
["Gedeo State", "Gedeo Zone", "Yirgacheffe", "15", "01", "02"],
];
/** First occurrence wins on a name collision — see the class comment. */
const buildMap = (pick: (row: (typeof ROWS)[number]) => [string, string]): Record<string, string> => {
const map: Record<string, string> = {};
for (const row of ROWS) {
const [name, code] = pick(row);
if (!(name in map)) map[name] = code;
}
return map;
};
export const ETHIOPIA_REGION_CODES: Record<string, string> = buildMap((r) => [r[0], r[3]]);
/** Zone name → code. Fed into `buyerCityCodes` — EIMS's "City" is really the buyer's zone. */
export const ETHIOPIA_ZONE_CODES: Record<string, string> = buildMap((r) => [r[1], r[4]]);
export const ETHIOPIA_WOREDA_CODES: Record<string, string> = buildMap((r) => [r[2], r[5]]);
/**
* Buyer records commonly store just the bare Addis Ababa sub-city name ("Bole", "Arada") as their
* woreda, not the source CSV's specific example-woreda name ("Bole Woreda 01") — confirmed live
* 2026-08-17 across three different buyers before any of them actually got past this check. Since
* the CSV lists exactly one representative woreda per Addis sub-city, alias the bare name to that
* same code rather than wait on a fuller table.
*/
const ADDIS_SUBCITY_ALIASES: Array<[bareName: string, csvZoneName: string]> = [
["Bole", "Bole Sub-City"],
["Kirkos", "Kirkos Sub-City"],
["Nifas Silk Lafto", "Nifas Silk Lafto"],
// Matches EIMS_BUYER_WEREDA_CODES' own existing spelling in .env — same zone, different hyphenation.
["Nefas Silk-Lafto", "Nifas Silk Lafto"],
["Yeka", "Yeka Sub-City"],
["Arada", "Arada Sub-City"],
];
for (const [bareName, csvZoneName] of ADDIS_SUBCITY_ALIASES) {
const row = ROWS.find((r) => r[1] === csvZoneName);
if (row && !(bareName in ETHIOPIA_WOREDA_CODES)) ETHIOPIA_WOREDA_CODES[bareName] = row[5];
}

View File

@@ -0,0 +1,275 @@
import { MorLocationTuple } from "./mor-locations.data";
import {
MorGeoMappingError,
normalizeName,
resolveMorGeo,
tryResolveMorGeo,
} from "./mor-location.resolver";
/**
* Rows copied verbatim out of the Ministry sheet (`EIMS_COUNTRY_REGION_VW`), chosen for the traps
* the real data contains rather than for tidiness:
*
* - BABILE and KERSA each exist in two different zones with different LOCALITY_NOs — the reason a
* global name lookup is unsafe and the hierarchy is mandatory.
* - ILLUBABOR has BURE twice under the same zone with different LOCALITY_NOs (691 and 890, the
* second with the Ministry's own trailing space) — a genuine ambiguity that must never be
* silently resolved to the first row.
* - "Wal-Mera" and "Akaki woreda" carry the sheet's mixed casing and punctuation.
*/
const FIXTURE: MorLocationTuple[] = [
[70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 190, "JIJIGA"],
[70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 194, "BABILE"],
[70, "Ethiopia", 6, "SOMALI", 30, "SITI ZONE", 197, "DENBEL"],
[70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 495, "BABILE"],
[70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 482, "KERSA"],
[70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 503, "KERSA"],
[70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 691, "BURE"],
[70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 890, "BURE "],
[70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 976, "Wal-Mera"],
[70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 909, "Akaki woreda"],
[70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1100, "WOREDA 1"],
[253, "Djibouti", 1, "DJIBOUTI", 1, "DJIBOUTI VILLE", 1, "BALBALA"],
];
const JIJIGA = {
country: "Ethiopia",
region: "SOMALI",
zone: "FAAFAN ZONE",
woreda: "JIJIGA",
};
describe("normalizeName", () => {
it("collapses whitespace, trims, and compares case-insensitively", () => {
expect(normalizeName(" FAAFAN ZONE ")).toBe("FAAFAN ZONE");
expect(normalizeName("faafan zone")).toBe("FAAFAN ZONE");
expect(normalizeName(" FAAFAN ZONE ")).toBe(normalizeName("faafan zone"));
});
it("normalizes harmless punctuation and hyphen/space differences", () => {
expect(normalizeName("Wal-Mera")).toBe("WAL MERA");
expect(normalizeName("Wal Mera")).toBe("WAL MERA");
expect(normalizeName("ZONE 1 (AYSSAITA)")).toBe("ZONE 1 AYSSAITA");
expect(normalizeName("Ber'ano")).toBe("BERANO");
expect(normalizeName("KEAHORE/HADAT/")).toBe("KEAHORE HADAT");
});
it("keeps digits, which several MoR locality names depend on", () => {
expect(normalizeName(" woreda 10 ")).toBe("WOREDA 10");
expect(normalizeName("WOREDA 1")).not.toBe(normalizeName("WOREDA 10"));
});
});
describe("resolveMorGeo", () => {
it("resolves the exact MoR spelling to the Ministry's own codes", () => {
expect(resolveMorGeo(JIJIGA, FIXTURE)).toEqual({
Country: "70",
Region: "6",
City: "31",
Wereda: "190",
});
});
it("resolves the EDR/e-Trade spellings through the alias layer", () => {
expect(
resolveMorGeo(
{
country: "Ethiopia",
region: "Somali",
zone: "Fafen",
woreda: "Jigjiga",
},
FIXTURE,
),
).toEqual({ Country: "70", Region: "6", City: "31", Wereda: "190" });
});
it("is case-insensitive", () => {
expect(
resolveMorGeo(
{
country: "ethiopia",
region: "somali",
zone: "faafan zone",
woreda: "jijiga",
},
FIXTURE,
),
).toEqual({ Country: "70", Region: "6", City: "31", Wereda: "190" });
});
it("ignores leading, trailing and repeated whitespace on every level", () => {
expect(
resolveMorGeo(
{
country: " Ethiopia ",
region: " SOMALI ",
zone: " FAAFAN ZONE ",
woreda: "\tJIJIGA ",
},
FIXTURE,
),
).toEqual({ Country: "70", Region: "6", City: "31", Wereda: "190" });
});
it("treats a hyphen as a space, in either direction", () => {
const expected = { Country: "70", Region: "2", City: "86", Wereda: "976" };
const base = {
country: "Ethiopia",
region: "Oromia",
zone: "Finfine Vic Spec",
};
expect(resolveMorGeo({ ...base, woreda: "Wal-Mera" }, FIXTURE)).toEqual(expected);
expect(resolveMorGeo({ ...base, woreda: "wal mera" }, FIXTURE)).toEqual(expected);
});
it("matches a zone whose MoR label carries the ' ZONE' suffix EDR does not store", () => {
expect(resolveMorGeo({ ...JIJIGA, zone: "Faafan" }, FIXTURE).City).toBe("31");
expect(
resolveMorGeo(
{
country: "Ethiopia",
region: "Somali",
zone: "Siti",
woreda: "Denbel",
},
FIXTURE,
),
).toEqual({ Country: "70", Region: "6", City: "30", Wereda: "197" });
});
describe("a locality name that exists in more than one zone", () => {
it("picks BABILE by its full hierarchy, never by name alone", () => {
expect(resolveMorGeo({ ...JIJIGA, woreda: "BABILE" }, FIXTURE).Wereda).toBe("194");
expect(
resolveMorGeo(
{
country: "Ethiopia",
region: "OROMIA",
zone: "MISRAK HARARGE",
woreda: "BABILE",
},
FIXTURE,
).Wereda,
).toBe("495");
});
it("picks KERSA by its full hierarchy", () => {
const oromia = { country: "Ethiopia", region: "OROMIA" };
expect(
resolveMorGeo({ ...oromia, zone: "MISRAK HARARGE", woreda: "KERSA" }, FIXTURE).Wereda,
).toBe("482");
expect(
resolveMorGeo({ ...oromia, zone: "JIMMA ZONE", woreda: "KERSA" }, FIXTURE).Wereda,
).toBe("503");
});
it("does not let a locality leak across regions", () => {
// DENBEL exists under SOMALI/SITI ZONE only — asking for it under OROMIA must fail, not
// fall back to the nationwide match the old flat maps would have found.
expect(() =>
resolveMorGeo(
{
country: "Ethiopia",
region: "OROMIA",
zone: "MISRAK HARARGE",
woreda: "DENBEL",
},
FIXTURE,
),
).toThrow(/no MoR LOCALITY_DESC match/);
});
});
describe("failures happen locally, before anything is filed", () => {
const cases: Array<[string, Record<string, string>, RegExp]> = [
["unknown country", { ...JIJIGA, country: "Wakanda" }, /no MoR COUNTRY_NAME match/],
["unknown region", { ...JIJIGA, region: "Atlantis" }, /no MoR PARISH_NAME match/],
["unknown zone", { ...JIJIGA, zone: "Nowhere Zone" }, /no MoR CITY_NAME match/],
["unknown woreda", { ...JIJIGA, woreda: "Example" }, /no MoR LOCALITY_DESC match/],
];
it.each(cases)("%s fails with an actionable validation error", (_label, input, pattern) => {
expect(() => resolveMorGeo(input, FIXTURE)).toThrow(MorGeoMappingError);
expect(() => resolveMorGeo(input, FIXTURE)).toThrow(pattern);
});
it("names the offending address in the message so the company record can be corrected", () => {
expect(() => resolveMorGeo({ ...JIJIGA, woreda: "Example" }, FIXTURE)).toThrow(
/country="Ethiopia", region="SOMALI", zone="FAAFAN ZONE", woreda="Example"/,
);
});
it("refuses an ambiguous locality instead of taking the first row", () => {
const input = {
country: "Ethiopia",
region: "OROMIA",
zone: "ILLUBABOR",
woreda: "BURE",
};
expect(() => resolveMorGeo(input, FIXTURE)).toThrow(MorGeoMappingError);
expect(() => resolveMorGeo(input, FIXTURE)).toThrow(/ambiguous/);
// Both colliding codes are named, and neither is silently selected.
expect(() => resolveMorGeo(input, FIXTURE)).toThrow(/691, 890/);
expect(tryResolveMorGeo(input, FIXTURE)).toBeNull();
});
it("fails loudly when the MoR master has not been generated yet", () => {
expect(() => resolveMorGeo(JIJIGA, [])).toThrow(/MoR location master is empty/);
});
});
it("reproduces MoR's numeric values unchanged, as strings", () => {
const codes = resolveMorGeo(JIJIGA, FIXTURE);
expect(codes).toEqual({
Country: "70",
Region: "6",
City: "31",
Wereda: "190",
});
for (const value of Object.values(codes)) {
expect(typeof value).toBe("string");
expect(value).toMatch(/^[0-9]+$/);
}
// The source row is the only origin of every code — no renumbering, no derivation.
const [countryNo, , parishNo, , cityNo, , localityNo] = FIXTURE[0];
expect(codes).toEqual({
Country: String(countryNo),
Region: String(parishNo),
City: String(cityNo),
Wereda: String(localityNo),
});
});
it("never emits an Open Admin Data ETxx identifier", () => {
for (const value of Object.values(resolveMorGeo(JIJIGA, FIXTURE))) {
expect(value).not.toMatch(/^ET/i);
}
});
it("treats a blank country as domestic, matching the column default", () => {
expect(resolveMorGeo({ ...JIJIGA, country: "" }, FIXTURE).Country).toBe("70");
expect(resolveMorGeo({ ...JIJIGA, country: null }, FIXTURE).Country).toBe("70");
});
it("resolves a named foreign country rather than defaulting it to Ethiopia", () => {
expect(
resolveMorGeo(
{
country: "Djibouti",
region: "DJIBOUTI",
zone: "DJIBOUTI VILLE",
woreda: "BALBALA",
},
FIXTURE,
),
).toEqual({ Country: "253", Region: "1", City: "1", Wereda: "1" });
});
it("accepts a company record that already holds a MoR code, but only a real one", () => {
expect(resolveMorGeo({ ...JIJIGA, region: "6" }, FIXTURE).Region).toBe("6");
expect(() => resolveMorGeo({ ...JIJIGA, region: "999" }, FIXTURE)).toThrow(
/no MoR PARISH_NAME match/,
);
});
});

View File

@@ -0,0 +1,255 @@
import { BadRequestException } from "@nestjs/common";
import { MOR_LOCATIONS, MorLocationTuple } from "./mor-locations.data";
/**
* Resolves an EDR company address to the Ministry of Revenues' own EIMS location codes, using the
* MoR location master (`EIMS_COUNTRY_REGION_VW`) shipped in `mor-locations.data.ts`.
*
* MoR's field names do not line up with either EDR's or generic Ethiopian administrative datasets,
* so the mapping is fixed by the Ministry sheet, not by interpretation:
*
* Company.country -> COUNTRY_NAME -> COUNTRY_NO -> BuyerDetails.Country
* Company.region -> PARISH_NAME -> PARISH_NO -> BuyerDetails.Region
* Company.zone -> CITY_NAME -> CITY_NO -> BuyerDetails.City
* Company.woreda -> LOCALITY_DESC -> LOCALITY_NO -> BuyerDetails.Wereda
*
* This replaces the previous `EIMS_BUYER_*_CODES` environment maps and the `ethiopia-geo-codes.ts`
* table they layered over. Both invented their codes (sequential "01".."15" per region, from a
* generic administrative CSV) and both looked names up **globally**, which cannot be correct:
* KERSA, GORO, BABILE and BURE each occur in several different zones with different LOCALITY_NOs.
* A global name lookup silently picked the first, i.e. filed a real invoice against whichever tax
* jurisdiction happened to sort first. Resolution here is strictly hierarchical — each level is
* searched only within the rows its parent already selected.
*
* Open Admin Data identifiers (`ET14`, `ET0407`, …) are unrelated to this code system and must
* never appear in an EIMS payload; nothing in this module can emit one, since every returned value
* comes from a numeric column of the Ministry sheet.
*/
export interface MorGeoCodes {
/** COUNTRY_NO as a string — `BuyerDetails.Country`. */
Country: string;
/** PARISH_NO as a string — `BuyerDetails.Region`. */
Region: string;
/** CITY_NO as a string — `BuyerDetails.City`. MoR calls the zone level "City". */
City: string;
/** LOCALITY_NO as a string — `BuyerDetails.Wereda`. */
Wereda: string;
}
export interface MorAddressInput {
country?: string | null;
region?: string | null;
zone?: string | null;
woreda?: string | null;
}
type Level = "country" | "region" | "zone" | "woreda";
/** Which tuple slots hold the name and the code at each level. */
const SLOTS: Record<Level, { name: 1 | 3 | 5 | 7; no: 0 | 2 | 4 | 6; column: string }> = {
country: { name: 1, no: 0, column: "COUNTRY_NAME" },
region: { name: 3, no: 2, column: "PARISH_NAME" },
zone: { name: 5, no: 4, column: "CITY_NAME" },
woreda: { name: 7, no: 6, column: "LOCALITY_DESC" },
};
/**
* One normalized form for both sides of every comparison. Deliberately conservative: it removes
* differences that cannot change which jurisdiction is meant (case, stray and repeated whitespace,
* hyphen/slash/parenthesis/apostrophe punctuation, combining accents) and nothing else. There is
* no fuzzy or edit-distance matching anywhere in this module — a near-miss must fail loudly rather
* than file an invoice against a neighbouring woreda.
*
* " FAAFAN ZONE " -> "FAAFAN ZONE"
* "Wal-Mera" -> "WAL MERA"
* "Ber'ano" -> "BERANO"
* "ZONE 1 (AYSSAITA)"-> "ZONE 1 AYSSAITA"
*/
const normalizeCache = new Map<string, string>();
export function normalizeName(value: string | null | undefined): string {
const raw = value ?? "";
const hit = normalizeCache.get(raw);
if (hit !== undefined) return hit;
const normalized = raw
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toUpperCase()
.replace(/['\u2018\u2019`]/g, "")
.replace(/[^A-Z0-9]+/g, " ")
.trim();
normalizeCache.set(raw, normalized);
return normalized;
}
/**
* Reviewed spelling differences between what EDR/e-Trade store and what the Ministry sheet calls
* the same place. Every entry is scoped to the administrative level it applies to, and to its
* parent where the name is not unique nationwide — so an alias can never reach across into another
* region's jurisdiction. `from`/`to` are compared normalized, so casing and spacing here are
* cosmetic.
*
* Add an entry only after confirming the two names are the same place in the Ministry sheet. This
* is the only sanctioned place for spelling compatibility; `mor-locations.data.ts` stays verbatim.
*/
interface MorAlias {
level: Exclude<Level, "country">;
/** Parent scope, normalized-compared. Omit a level to leave the alias unscoped at that level. */
region?: string;
zone?: string;
from: string;
to: string;
}
const ALIASES: MorAlias[] = [
// e-Trade and the customer portal both spell the Somali zone "Fafen"; MoR spells it "FAAFAN
// ZONE". Confirmed same zone (CITY_NO 31) — this is the buyer that first exposed the whole
// fabricated-code problem.
{ level: "zone", region: "SOMALI", from: "Fafen", to: "FAAFAN ZONE" },
// MoR's own capital of that zone is "JIJIGA"; every other source spells it "Jigjiga".
{
level: "woreda",
region: "SOMALI",
zone: "FAAFAN ZONE",
from: "Jigjiga",
to: "JIJIGA",
},
];
/**
* MoR suffixes many zone labels with " ZONE" ("JIMMA ZONE", "FAAFAN ZONE", "SITI ZONE") while EDR
* stores the bare name. Retrying the suffixed spelling is an exact match against a second candidate
* string, scoped to the already-resolved region — not fuzzy matching — and it removes a long tail
* of otherwise hand-maintained aliases. Applied to the zone level only: locality suffixes
* ("WOREDA", "TOWN ADMINISTRATION") are not mechanical and could select a different place.
*/
const zoneSuffixCandidates = (normalized: string): string[] =>
normalized.endsWith(" ZONE") ? [] : [`${normalized} ZONE`];
export class MorGeoMappingError extends BadRequestException {
constructor(code: "EIMS_GEO_MAPPING_FAILED" | "EIMS_GEO_AMBIGUOUS", message: string) {
super({ code, message });
}
}
/** Renders the address being resolved for an error message. No customer-identifying data. */
const describe = (input: MorAddressInput): string =>
`country="${input.country ?? ""}", region="${input.region ?? ""}", ` +
`zone="${input.zone ?? ""}", woreda="${input.woreda ?? ""}"`;
function matchLevel(
rows: MorLocationTuple[],
level: Level,
raw: string | null | undefined,
parents: { region?: string; zone?: string },
input: MorAddressInput,
): { no: number; rows: MorLocationTuple[] } {
const { name: nameSlot, no: noSlot, column } = SLOTS[level];
const wanted = normalizeName(raw);
const candidates: string[] = [];
if (wanted) {
candidates.push(wanted);
for (const alias of ALIASES) {
if (alias.level !== level) continue;
if (alias.region && normalizeName(alias.region) !== parents.region) continue;
if (alias.zone && normalizeName(alias.zone) !== parents.zone) continue;
if (normalizeName(alias.from) === wanted) candidates.push(normalizeName(alias.to));
}
if (level === "zone") candidates.push(...zoneSuffixCandidates(wanted));
}
let matched: MorLocationTuple[] = [];
for (const candidate of candidates) {
matched = rows.filter((row) => normalizeName(row[nameSlot] as string) === candidate);
if (matched.length > 0) break;
}
// A company record that already holds the MoR code itself resolves too — but only when that code
// genuinely exists at this level under this parent. An unvalidated numeric pass-through is how a
// wrong code reaches MoR without anything noticing.
if (matched.length === 0 && /^[0-9]{1,6}$/.test((raw ?? "").trim())) {
const asCode = Number((raw ?? "").trim());
matched = rows.filter((row) => row[noSlot] === asCode);
}
if (matched.length === 0) {
throw new MorGeoMappingError(
"EIMS_GEO_MAPPING_FAILED",
`EIMS geographic mapping failed: no MoR ${column} match for ${describe(input)}.`,
);
}
const distinct = [...new Set(matched.map((row) => row[noSlot] as number))];
if (distinct.length > 1) {
throw new MorGeoMappingError(
"EIMS_GEO_AMBIGUOUS",
`EIMS geographic mapping is ambiguous: MoR ${column} "${(raw ?? "").trim()}" matches ` +
`${distinct.length} different codes (${distinct.sort((a, b) => a - b).join(", ")}) for ` +
`${describe(input)}. Correct the company address or the MoR reference data; an ambiguous ` +
"location is never filed.",
);
}
return { no: distinct[0], rows: matched };
}
/**
* Resolves the full hierarchy, or throws a `BadRequestException` naming the level that failed.
*
* Never guesses and never returns a partial result: an unknown or ambiguous location must stop the
* filing here, locally, before any MoR request and before an EIMS counter is consumed.
*/
export function resolveMorGeo(
input: MorAddressInput,
rows: MorLocationTuple[] = MOR_LOCATIONS,
): MorGeoCodes {
if (rows.length === 0) {
throw new MorGeoMappingError(
"EIMS_GEO_MAPPING_FAILED",
"EIMS geographic mapping failed: the MoR location master is empty. Generate it with " +
"`pnpm --filter @edr/freight-api eims:import-locations <workbook.xlsx>`.",
);
}
// `companies.country` defaults to 'Ethiopia' and is often left blank on older rows; blank means
// domestic here, exactly as the column default says. A *named* foreign country is resolved like
// any other and fails if MoR does not list it — it is never quietly filed as Ethiopia.
const country = (input.country ?? "").trim() || "Ethiopia";
const inCountry = matchLevel(rows, "country", country, {}, input);
const inRegion = matchLevel(inCountry.rows, "region", input.region, {}, input);
const regionScope = normalizeName(inRegion.rows[0][SLOTS.region.name] as string);
const inZone = matchLevel(inRegion.rows, "zone", input.zone, { region: regionScope }, input);
const zoneScope = normalizeName(inZone.rows[0][SLOTS.zone.name] as string);
const inWoreda = matchLevel(
inZone.rows,
"woreda",
input.woreda,
{
region: regionScope,
zone: zoneScope,
},
input,
);
return {
Country: String(inCountry.no),
Region: String(inRegion.no),
City: String(inZone.no),
Wereda: String(inWoreda.no),
};
}
/** Non-throwing variant for callers that already have a working fallback (the seller identity). */
export function tryResolveMorGeo(
input: MorAddressInput,
rows: MorLocationTuple[] = MOR_LOCATIONS,
): MorGeoCodes | null {
try {
return resolveMorGeo(input, rows);
} catch {
return null;
}
}

View File

@@ -0,0 +1,18 @@
/**
* GENERATED FILE — do not hand-edit.
*
* MoR EIMS location master (`EIMS_COUNTRY_REGION_VW`), the Ministry's own geographic reference
* data. Regenerate from a supplied workbook with:
*
* pnpm --filter @edr/freight-api eims:import-locations <path-to.xlsx>
*
* Values are reproduced verbatim from the Ministry sheet — original spelling, original casing,
* original numbering, duplicates included. Nothing here is cleaned up or renumbered: this file is
* the traceable copy of the source. Spelling compatibility between EDR/e-Trade names and MoR names
* belongs in `mor-location.resolver.ts`'s normalization and alias layer, never here.
*/
/** `[COUNTRY_NO, COUNTRY_NAME, PARISH_NO, PARISH_NAME, CITY_NO, CITY_NAME, LOCALITY_NO, LOCALITY_DESC]` */
export type MorLocationTuple = [number, string, number, string, number, string, number, string];
export const MOR_LOCATIONS: MorLocationTuple[] = [];