mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 18:55:42 +00:00
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:
@@ -70,62 +70,3 @@ describe("eims.config — private key / certificate resolution", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("eims.config — baked-in Ethiopia region/zone/woreda codes", () => {
|
||||
it("resolves a known region/wereda/zone with no env var set at all", () => {
|
||||
withEnv(
|
||||
{ ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" },
|
||||
() => {
|
||||
const cfg = eimsConfigFactory();
|
||||
expect(cfg.invoice.buyerRegionCodes.Somali).toBe("05");
|
||||
expect(cfg.invoice.buyerWeredaCodes["Jijiga Town"]).toBe("02");
|
||||
expect(cfg.invoice.buyerCityCodes.Fafan).toBe("01");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("an env var entry overrides the baked-in code for the same name", () => {
|
||||
withEnv(
|
||||
{
|
||||
...REQUIRED,
|
||||
EIMS_PRIVATE_KEY: "x",
|
||||
EIMS_CERTIFICATE_PATH: "/dev/null",
|
||||
EIMS_BUYER_REGION_CODES: "Somali=99",
|
||||
},
|
||||
() => {
|
||||
expect(eimsConfigFactory().invoice.buyerRegionCodes.Somali).toBe("99");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("an env var still adds a name the baked-in table doesn't have (a spelling variant)", () => {
|
||||
withEnv(
|
||||
{
|
||||
...REQUIRED,
|
||||
EIMS_PRIVATE_KEY: "x",
|
||||
EIMS_CERTIFICATE_PATH: "/dev/null",
|
||||
EIMS_BUYER_CITY_CODES: "Fafen=01",
|
||||
},
|
||||
() => {
|
||||
const codes = eimsConfigFactory().invoice.buyerCityCodes;
|
||||
expect(codes.Fafen).toBe("01");
|
||||
expect(codes.Fafan).toBe("01"); // baked-in entry still present alongside it
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves the bare Addis Ababa sub-city name a buyer profile actually stores, not the CSV's example-woreda name", () => {
|
||||
withEnv(
|
||||
{ ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" },
|
||||
() => {
|
||||
const codes = eimsConfigFactory().invoice.buyerWeredaCodes;
|
||||
expect(codes.Bole).toBe("01");
|
||||
expect(codes.Arada).toBe("01");
|
||||
expect(codes.Kirkos).toBe("01");
|
||||
expect(codes.Yeka).toBe("01");
|
||||
expect(codes["Nifas Silk Lafto"]).toBe("13");
|
||||
expect(codes["Nefas Silk-Lafto"]).toBe("13");
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { registerAs } from "@nestjs/config";
|
||||
|
||||
import { ETHIOPIA_REGION_CODES, ETHIOPIA_WOREDA_CODES, ETHIOPIA_ZONE_CODES } from "./ethiopia-geo-codes";
|
||||
|
||||
/**
|
||||
* Ethiopian MoR EIMS e-invoicing gateway.
|
||||
@@ -99,35 +98,6 @@ export interface EimsInvoiceConfig {
|
||||
paymentMode: string;
|
||||
paymentTerm: string;
|
||||
unitDefault: string;
|
||||
/**
|
||||
* Domestic fallback only — used when the buyer's `Company.country` is empty or "Ethiopia" (the
|
||||
* column's own default) and not already listed in `buyerCountryCodes`. A genuinely foreign
|
||||
* buyer must be in `buyerCountryCodes` by name or the mapping fails locally; this value is never
|
||||
* applied to them, so an unconfigured foreign country can't silently be filed as Ethiopia.
|
||||
*/
|
||||
buyerCountryCode: string | null;
|
||||
/**
|
||||
* Country name → MoR code, from `EIMS_BUYER_COUNTRY_CODES` ("Ethiopia=231,Djibouti=071"). Format
|
||||
* unconfirmed (unlike Region/Wereda, MoR has never named a Country regex), so — unlike them —
|
||||
* this is not validated against a fixed digit pattern, only looked up by name.
|
||||
*/
|
||||
buyerCountryCodes: Record<string, string>;
|
||||
/**
|
||||
* Buyer region name → MoR numeric code, from `EIMS_BUYER_REGION_CODES`
|
||||
* ("Addis Ababa=13,Oromia=4"). A buyer whose region is neither a code nor in this map fails
|
||||
* locally rather than being filed with a guessed one.
|
||||
*/
|
||||
buyerRegionCodes: Record<string, string>;
|
||||
/** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */
|
||||
buyerWeredaCodes: Record<string, string>;
|
||||
/**
|
||||
* Buyer *zone* name → MoR City code, from `EIMS_BUYER_CITY_CODES` ("Kirkos=101"). `Company` has
|
||||
* no dedicated city column — Zone is the closest match in EDR's own data. Optional, unlike
|
||||
* Region/Wereda: MoR has never required City on a live buyer (confirmed — filing already
|
||||
* succeeds with it null), so an unmapped zone falls back to null rather than failing the
|
||||
* mapping.
|
||||
*/
|
||||
buyerCityCodes: Record<string, string>;
|
||||
/**
|
||||
* Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` +
|
||||
* `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to
|
||||
@@ -258,13 +228,6 @@ export default registerAs("eims", (): EimsConfig => {
|
||||
paymentMode: process.env.EIMS_PAYMENT_MODE ?? "",
|
||||
paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "",
|
||||
unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "",
|
||||
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
|
||||
buyerCountryCodes: parseCodeMap(process.env.EIMS_BUYER_COUNTRY_CODES),
|
||||
// Baked-in Ethiopia reference table first, env var entries win on a name collision — lets a
|
||||
// deployment override or add to it without a redeploy. See ethiopia-geo-codes.ts.
|
||||
buyerRegionCodes: { ...ETHIOPIA_REGION_CODES, ...parseCodeMap(process.env.EIMS_BUYER_REGION_CODES) },
|
||||
buyerWeredaCodes: { ...ETHIOPIA_WOREDA_CODES, ...parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES) },
|
||||
buyerCityCodes: { ...ETHIOPIA_ZONE_CODES, ...parseCodeMap(process.env.EIMS_BUYER_CITY_CODES) },
|
||||
taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE),
|
||||
taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE),
|
||||
exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE),
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
/**
|
||||
* MoR EIMS region/zone/woreda codes, by name — the baked-in fallback under
|
||||
* `EIMS_BUYER_REGION_CODES`/`EIMS_BUYER_WEREDA_CODES`/`EIMS_BUYER_CITY_CODES` (zone is the closest
|
||||
* match to EIMS's "City", per `eims-invoice.mapper.ts`).
|
||||
*
|
||||
* Before this existed, every buyer from a not-yet-seen region/zone/woreda crashed EIMS filing until
|
||||
* someone hunted down the code and added it to an env var by hand — happened three times in one
|
||||
* afternoon (2026-08-17: Somali region, Fafan zone, Jigjiga woreda, even the Ethiopia country code
|
||||
* itself were all unset). Ethiopia's administrative divisions are fixed, known, reference data, not
|
||||
* something that should be maintained reactively per buyer. Source: `ethiopia_administrative_
|
||||
* hierarchy_master.csv`, supplied 2026-08-17 — NOT exhaustive (a representative sample per region,
|
||||
* not all ~1000 real woredas), extend as new gaps surface.
|
||||
*
|
||||
* The env vars stay wired in ahead of this table (see `eims.config.ts`) — for a quick correction
|
||||
* without a redeploy, or a name spelled differently in a buyer's profile than in this table (already
|
||||
* hit live: DB has zone "Fafen", this table's official spelling is "Fafan" — same zone, matching is
|
||||
* case/space-insensitive but not spelling-tolerant, so the env var override is still how that buyer
|
||||
* actually resolves; this table mainly helps the *next* buyer whose profile spelling matches).
|
||||
*
|
||||
* ponytail: region names are unique nationwide (only ~15), safe as a flat map. Zone and woreda names
|
||||
* are not always unique across different regions (e.g. "North Shewa" is both an Amhara zone and an
|
||||
* Oromia zone, different codes) — `Company` stores region/zone/woreda as three independent strings,
|
||||
* no parent linkage, so a flat name lookup can't disambiguate. First occurrence in the source data
|
||||
* wins on a collision. Only affects the optional `City` field (zone) — never blocks filing, unlike
|
||||
* Region/Wereda. A correct fix needs `Company` to store a linked hierarchy, not just three strings;
|
||||
* out of scope here. Upgrade path: key this by `${region}/${zone}` once that linkage exists.
|
||||
*/
|
||||
const ROWS: Array<[region: string, zone: string, woreda: string, regionCode: string, zoneCode: string, woredaCode: string]> = [
|
||||
["Tigray", "Western Tigray", "Humera", "01", "01", "01"],
|
||||
["Tigray", "Western Tigray", "Kafta Humera", "01", "01", "02"],
|
||||
["Tigray", "Western Tigray", "Tsegede", "01", "01", "03"],
|
||||
["Tigray", "North Western Tigray", "Shire Endaselassie", "01", "02", "01"],
|
||||
["Tigray", "North Western Tigray", "Sheraro", "01", "02", "02"],
|
||||
["Tigray", "Central Tigray", "Axum", "01", "03", "01"],
|
||||
["Tigray", "Central Tigray", "Adwa", "01", "03", "02"],
|
||||
["Tigray", "Eastern Tigray", "Adigrat", "01", "04", "01"],
|
||||
["Tigray", "Southern Tigray", "Maychew", "01", "05", "01"],
|
||||
["Tigray", "Mekelle Special Zone", "Mekelle City", "01", "06", "01"],
|
||||
["Afar", "Awusi Rasu (Zone 1)", "Asayita", "02", "01", "01"],
|
||||
["Afar", "Awusi Rasu (Zone 1)", "Semera-Logiya", "02", "01", "02"],
|
||||
["Afar", "Kilbet Rasu (Zone 2)", "Abala", "02", "02", "01"],
|
||||
["Afar", "Gabi Rasu (Zone 3)", "Awash Fentale", "02", "03", "01"],
|
||||
["Afar", "Fantena Rasu (Zone 4)", "Yalo", "02", "04", "01"],
|
||||
["Afar", "Hari Rasu (Zone 5)", "Telalak", "02", "05", "01"],
|
||||
["Amhara", "North Gondar", "Debark", "03", "01", "01"],
|
||||
["Amhara", "South Gondar", "Debre Tabor", "03", "02", "01"],
|
||||
["Amhara", "North Wollo", "Woldiya", "03", "03", "01"],
|
||||
["Amhara", "South Wollo", "Dessie Town", "03", "04", "01"],
|
||||
["Amhara", "North Shewa", "Debre Berhan", "03", "05", "01"],
|
||||
["Amhara", "East Gojjam", "Debre Markos", "03", "06", "01"],
|
||||
["Amhara", "West Gojjam", "Finote Selam", "03", "07", "01"],
|
||||
["Amhara", "Wag Hemra", "Sekota", "03", "08", "01"],
|
||||
["Amhara", "Awi", "Injibara", "03", "09", "01"],
|
||||
["Amhara", "Oromia Special Zone", "Kemise", "03", "10", "01"],
|
||||
["Amhara", "Bahir Dar Special Zone", "Bahir Dar City", "03", "11", "01"],
|
||||
["Amhara", "Gondar Special Zone", "Gondar City", "03", "12", "01"],
|
||||
["Oromia", "North Shewa", "Fiche", "04", "01", "01"],
|
||||
["Oromia", "South West Shewa", "Waliso", "04", "02", "01"],
|
||||
["Oromia", "East Shewa", "Adama Town", "04", "03", "01"],
|
||||
["Oromia", "East Shewa", "Bishoftu Town", "04", "03", "02"],
|
||||
["Oromia", "West Shewa", "Ambo", "04", "04", "01"],
|
||||
["Oromia", "Arsi", "Asella", "04", "05", "01"],
|
||||
["Oromia", "West Arsi", "Shashemene", "04", "06", "01"],
|
||||
["Oromia", "Bale", "Robe", "04", "07", "01"],
|
||||
["Oromia", "East Hararghe", "Harar Outskirts", "04", "08", "01"],
|
||||
["Oromia", "West Hararghe", "Chiro", "04", "09", "01"],
|
||||
["Oromia", "Jimma", "Jimma Town", "04", "10", "01"],
|
||||
["Oromia", "Illubabor", "Mettu", "04", "11", "01"],
|
||||
["Oromia", "Buno Bedele", "Bedele", "04", "12", "01"],
|
||||
["Oromia", "Welega (West)", "Gimbi", "04", "13", "01"],
|
||||
["Oromia", "Welega (East)", "Nekemte", "04", "14", "01"],
|
||||
["Oromia", "Horo Guduru Welega", "Shambu", "04", "15", "01"],
|
||||
["Oromia", "Kelam Welega", "Dembidolo", "04", "16", "01"],
|
||||
["Oromia", "Borena", "Yabelo", "04", "17", "01"],
|
||||
["Oromia", "Guji", "Negele Borana", "04", "18", "01"],
|
||||
["Oromia", "West Guji", "Bule Hora", "04", "19", "01"],
|
||||
["Oromia", "East Bale", "Ginir", "04", "20", "01"],
|
||||
["Oromia", "Sheger City", "Sululta", "04", "21", "01"],
|
||||
["Somali", "Fafan", "Jijiga Woreda", "05", "01", "01"],
|
||||
["Somali", "Fafan", "Jijiga Town", "05", "01", "02"],
|
||||
["Somali", "Fafan", "Awbare", "05", "01", "03"],
|
||||
["Somali", "Sitti", "Shinile", "05", "02", "01"],
|
||||
["Somali", "Erer", "Fiq", "05", "03", "01"],
|
||||
["Somali", "Jarar", "Degehabur", "05", "04", "01"],
|
||||
["Somali", "Nogob", "Segeg", "05", "05", "01"],
|
||||
["Somali", "Korahe", "Kebridehar", "05", "06", "01"],
|
||||
["Somali", "Shabelle", "Gode", "05", "07", "01"],
|
||||
["Somali", "Afder", "Afder Woreda", "05", "08", "01"],
|
||||
["Somali", "Liben", "Filtu", "05", "09", "01"],
|
||||
["Somali", "Dhawa", "Mubarak", "05", "10", "01"],
|
||||
["Somali", "Dollo", "Warder", "05", "11", "01"],
|
||||
["Benishangul-Gumuz", "Asosa", "Asosa Woreda", "06", "01", "01"],
|
||||
["Benishangul-Gumuz", "Kamasashi", "Kamasashi Woreda", "06", "02", "01"],
|
||||
["Benishangul-Gumuz", "Metekel", "Gilgel Beles", "06", "03", "01"],
|
||||
["Southern Ethiopia", "Wolayta", "Sodo Zuria", "07", "01", "01"],
|
||||
["Southern Ethiopia", "Wolayta", "Sodo Town", "07", "01", "02"],
|
||||
["Southern Ethiopia", "Gamo", "Arba Minch Town", "07", "02", "01"],
|
||||
["Southern Ethiopia", "Gofa", "Sawla", "07", "03", "01"],
|
||||
["Southern Ethiopia", "Konso", "Konso Woreda", "07", "04", "01"],
|
||||
["Southern Ethiopia", "South Omo", "Jinka", "07", "05", "01"],
|
||||
["Gambela", "Anywaa", "Gambela Zuria", "08", "01", "01"],
|
||||
["Gambela", "Nuer", "Lare", "08", "02", "01"],
|
||||
["Gambela", "Majang", "Metu Zuria part", "08", "03", "01"],
|
||||
["Harari", "Harar Hundanee", "Amir Nur Woreda", "09", "01", "01"],
|
||||
["Harari", "Harar Hundanee", "Abadir Woreda", "09", "01", "02"],
|
||||
["Addis Ababa", "Bole Sub-City", "Bole Woreda 01", "10", "01", "01"],
|
||||
["Addis Ababa", "Kirkos Sub-City", "Kirkos Woreda 01", "10", "02", "01"],
|
||||
["Addis Ababa", "Nifas Silk Lafto", "NSL Woreda 13", "10", "03", "13"],
|
||||
["Addis Ababa", "Yeka Sub-City", "Yeka Woreda 01", "10", "04", "01"],
|
||||
["Addis Ababa", "Arada Sub-City", "Arada Woreda 01", "10", "05", "01"],
|
||||
["Dire Dawa", "Dire Dawa Urban", "Melka Jebdu", "11", "01", "01"],
|
||||
["Dire Dawa", "Dire Dawa Rural", "Gurgura", "11", "02", "01"],
|
||||
["Sidama", "Hawassa City Admin", "Hayek Chereka", "12", "01", "01"],
|
||||
["Sidama", "Sidama Zuria", "Yirgalem Town", "12", "02", "01"],
|
||||
["Sidama", "Sidama Zuria", "Aleta Wendo", "12", "02", "02"],
|
||||
["Southwest Ethiopia", "Keffa", "Bonga Town", "13", "01", "01"],
|
||||
["Southwest Ethiopia", "Sheka", "Mappi Zuria", "13", "02", "01"],
|
||||
["Southwest Ethiopia", "Bench Sheko", "Mizan Aman", "13", "03", "01"],
|
||||
["Central Ethiopia", "Gurage", "Wolkite", "14", "01", "01"],
|
||||
["Central Ethiopia", "Hadiya", "Hosaina", "14", "02", "01"],
|
||||
["Central Ethiopia", "Silte", "Worabe", "14", "03", "01"],
|
||||
["Gedeo State", "Gedeo Zone", "Dilla Zuria", "15", "01", "01"],
|
||||
["Gedeo State", "Gedeo Zone", "Yirgacheffe", "15", "01", "02"],
|
||||
];
|
||||
|
||||
/** First occurrence wins on a name collision — see the class comment. */
|
||||
const buildMap = (pick: (row: (typeof ROWS)[number]) => [string, string]): Record<string, string> => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const row of ROWS) {
|
||||
const [name, code] = pick(row);
|
||||
if (!(name in map)) map[name] = code;
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
export const ETHIOPIA_REGION_CODES: Record<string, string> = buildMap((r) => [r[0], r[3]]);
|
||||
/** Zone name → code. Fed into `buyerCityCodes` — EIMS's "City" is really the buyer's zone. */
|
||||
export const ETHIOPIA_ZONE_CODES: Record<string, string> = buildMap((r) => [r[1], r[4]]);
|
||||
export const ETHIOPIA_WOREDA_CODES: Record<string, string> = buildMap((r) => [r[2], r[5]]);
|
||||
|
||||
/**
|
||||
* Buyer records commonly store just the bare Addis Ababa sub-city name ("Bole", "Arada") as their
|
||||
* woreda, not the source CSV's specific example-woreda name ("Bole Woreda 01") — confirmed live
|
||||
* 2026-08-17 across three different buyers before any of them actually got past this check. Since
|
||||
* the CSV lists exactly one representative woreda per Addis sub-city, alias the bare name to that
|
||||
* same code rather than wait on a fuller table.
|
||||
*/
|
||||
const ADDIS_SUBCITY_ALIASES: Array<[bareName: string, csvZoneName: string]> = [
|
||||
["Bole", "Bole Sub-City"],
|
||||
["Kirkos", "Kirkos Sub-City"],
|
||||
["Nifas Silk Lafto", "Nifas Silk Lafto"],
|
||||
// Matches EIMS_BUYER_WEREDA_CODES' own existing spelling in .env — same zone, different hyphenation.
|
||||
["Nefas Silk-Lafto", "Nifas Silk Lafto"],
|
||||
["Yeka", "Yeka Sub-City"],
|
||||
["Arada", "Arada Sub-City"],
|
||||
];
|
||||
for (const [bareName, csvZoneName] of ADDIS_SUBCITY_ALIASES) {
|
||||
const row = ROWS.find((r) => r[1] === csvZoneName);
|
||||
if (row && !(bareName in ETHIOPIA_WOREDA_CODES)) ETHIOPIA_WOREDA_CODES[bareName] = row[5];
|
||||
}
|
||||
275
apps/edr-freight-api/src/config/mor-location.resolver.spec.ts
Normal file
275
apps/edr-freight-api/src/config/mor-location.resolver.spec.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { MorLocationTuple } from "./mor-locations.data";
|
||||
import {
|
||||
MorGeoMappingError,
|
||||
normalizeName,
|
||||
resolveMorGeo,
|
||||
tryResolveMorGeo,
|
||||
} from "./mor-location.resolver";
|
||||
|
||||
/**
|
||||
* Rows copied verbatim out of the Ministry sheet (`EIMS_COUNTRY_REGION_VW`), chosen for the traps
|
||||
* the real data contains rather than for tidiness:
|
||||
*
|
||||
* - BABILE and KERSA each exist in two different zones with different LOCALITY_NOs — the reason a
|
||||
* global name lookup is unsafe and the hierarchy is mandatory.
|
||||
* - ILLUBABOR has BURE twice under the same zone with different LOCALITY_NOs (691 and 890, the
|
||||
* second with the Ministry's own trailing space) — a genuine ambiguity that must never be
|
||||
* silently resolved to the first row.
|
||||
* - "Wal-Mera" and "Akaki woreda" carry the sheet's mixed casing and punctuation.
|
||||
*/
|
||||
const FIXTURE: MorLocationTuple[] = [
|
||||
[70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 190, "JIJIGA"],
|
||||
[70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 194, "BABILE"],
|
||||
[70, "Ethiopia", 6, "SOMALI", 30, "SITI ZONE", 197, "DENBEL"],
|
||||
[70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 495, "BABILE"],
|
||||
[70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 482, "KERSA"],
|
||||
[70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 503, "KERSA"],
|
||||
[70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 691, "BURE"],
|
||||
[70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 890, "BURE "],
|
||||
[70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 976, "Wal-Mera"],
|
||||
[70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 909, "Akaki woreda"],
|
||||
[70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1100, "WOREDA 1"],
|
||||
[253, "Djibouti", 1, "DJIBOUTI", 1, "DJIBOUTI VILLE", 1, "BALBALA"],
|
||||
];
|
||||
|
||||
const JIJIGA = {
|
||||
country: "Ethiopia",
|
||||
region: "SOMALI",
|
||||
zone: "FAAFAN ZONE",
|
||||
woreda: "JIJIGA",
|
||||
};
|
||||
|
||||
describe("normalizeName", () => {
|
||||
it("collapses whitespace, trims, and compares case-insensitively", () => {
|
||||
expect(normalizeName(" FAAFAN ZONE ")).toBe("FAAFAN ZONE");
|
||||
expect(normalizeName("faafan zone")).toBe("FAAFAN ZONE");
|
||||
expect(normalizeName(" FAAFAN ZONE ")).toBe(normalizeName("faafan zone"));
|
||||
});
|
||||
|
||||
it("normalizes harmless punctuation and hyphen/space differences", () => {
|
||||
expect(normalizeName("Wal-Mera")).toBe("WAL MERA");
|
||||
expect(normalizeName("Wal Mera")).toBe("WAL MERA");
|
||||
expect(normalizeName("ZONE 1 (AYSSAITA)")).toBe("ZONE 1 AYSSAITA");
|
||||
expect(normalizeName("Ber'ano")).toBe("BERANO");
|
||||
expect(normalizeName("KEAHORE/HADAT/")).toBe("KEAHORE HADAT");
|
||||
});
|
||||
|
||||
it("keeps digits, which several MoR locality names depend on", () => {
|
||||
expect(normalizeName(" woreda 10 ")).toBe("WOREDA 10");
|
||||
expect(normalizeName("WOREDA 1")).not.toBe(normalizeName("WOREDA 10"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMorGeo", () => {
|
||||
it("resolves the exact MoR spelling to the Ministry's own codes", () => {
|
||||
expect(resolveMorGeo(JIJIGA, FIXTURE)).toEqual({
|
||||
Country: "70",
|
||||
Region: "6",
|
||||
City: "31",
|
||||
Wereda: "190",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves the EDR/e-Trade spellings through the alias layer", () => {
|
||||
expect(
|
||||
resolveMorGeo(
|
||||
{
|
||||
country: "Ethiopia",
|
||||
region: "Somali",
|
||||
zone: "Fafen",
|
||||
woreda: "Jigjiga",
|
||||
},
|
||||
FIXTURE,
|
||||
),
|
||||
).toEqual({ Country: "70", Region: "6", City: "31", Wereda: "190" });
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
expect(
|
||||
resolveMorGeo(
|
||||
{
|
||||
country: "ethiopia",
|
||||
region: "somali",
|
||||
zone: "faafan zone",
|
||||
woreda: "jijiga",
|
||||
},
|
||||
FIXTURE,
|
||||
),
|
||||
).toEqual({ Country: "70", Region: "6", City: "31", Wereda: "190" });
|
||||
});
|
||||
|
||||
it("ignores leading, trailing and repeated whitespace on every level", () => {
|
||||
expect(
|
||||
resolveMorGeo(
|
||||
{
|
||||
country: " Ethiopia ",
|
||||
region: " SOMALI ",
|
||||
zone: " FAAFAN ZONE ",
|
||||
woreda: "\tJIJIGA ",
|
||||
},
|
||||
FIXTURE,
|
||||
),
|
||||
).toEqual({ Country: "70", Region: "6", City: "31", Wereda: "190" });
|
||||
});
|
||||
|
||||
it("treats a hyphen as a space, in either direction", () => {
|
||||
const expected = { Country: "70", Region: "2", City: "86", Wereda: "976" };
|
||||
const base = {
|
||||
country: "Ethiopia",
|
||||
region: "Oromia",
|
||||
zone: "Finfine Vic Spec",
|
||||
};
|
||||
expect(resolveMorGeo({ ...base, woreda: "Wal-Mera" }, FIXTURE)).toEqual(expected);
|
||||
expect(resolveMorGeo({ ...base, woreda: "wal mera" }, FIXTURE)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("matches a zone whose MoR label carries the ' ZONE' suffix EDR does not store", () => {
|
||||
expect(resolveMorGeo({ ...JIJIGA, zone: "Faafan" }, FIXTURE).City).toBe("31");
|
||||
expect(
|
||||
resolveMorGeo(
|
||||
{
|
||||
country: "Ethiopia",
|
||||
region: "Somali",
|
||||
zone: "Siti",
|
||||
woreda: "Denbel",
|
||||
},
|
||||
FIXTURE,
|
||||
),
|
||||
).toEqual({ Country: "70", Region: "6", City: "30", Wereda: "197" });
|
||||
});
|
||||
|
||||
describe("a locality name that exists in more than one zone", () => {
|
||||
it("picks BABILE by its full hierarchy, never by name alone", () => {
|
||||
expect(resolveMorGeo({ ...JIJIGA, woreda: "BABILE" }, FIXTURE).Wereda).toBe("194");
|
||||
expect(
|
||||
resolveMorGeo(
|
||||
{
|
||||
country: "Ethiopia",
|
||||
region: "OROMIA",
|
||||
zone: "MISRAK HARARGE",
|
||||
woreda: "BABILE",
|
||||
},
|
||||
FIXTURE,
|
||||
).Wereda,
|
||||
).toBe("495");
|
||||
});
|
||||
|
||||
it("picks KERSA by its full hierarchy", () => {
|
||||
const oromia = { country: "Ethiopia", region: "OROMIA" };
|
||||
expect(
|
||||
resolveMorGeo({ ...oromia, zone: "MISRAK HARARGE", woreda: "KERSA" }, FIXTURE).Wereda,
|
||||
).toBe("482");
|
||||
expect(
|
||||
resolveMorGeo({ ...oromia, zone: "JIMMA ZONE", woreda: "KERSA" }, FIXTURE).Wereda,
|
||||
).toBe("503");
|
||||
});
|
||||
|
||||
it("does not let a locality leak across regions", () => {
|
||||
// DENBEL exists under SOMALI/SITI ZONE only — asking for it under OROMIA must fail, not
|
||||
// fall back to the nationwide match the old flat maps would have found.
|
||||
expect(() =>
|
||||
resolveMorGeo(
|
||||
{
|
||||
country: "Ethiopia",
|
||||
region: "OROMIA",
|
||||
zone: "MISRAK HARARGE",
|
||||
woreda: "DENBEL",
|
||||
},
|
||||
FIXTURE,
|
||||
),
|
||||
).toThrow(/no MoR LOCALITY_DESC match/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("failures happen locally, before anything is filed", () => {
|
||||
const cases: Array<[string, Record<string, string>, RegExp]> = [
|
||||
["unknown country", { ...JIJIGA, country: "Wakanda" }, /no MoR COUNTRY_NAME match/],
|
||||
["unknown region", { ...JIJIGA, region: "Atlantis" }, /no MoR PARISH_NAME match/],
|
||||
["unknown zone", { ...JIJIGA, zone: "Nowhere Zone" }, /no MoR CITY_NAME match/],
|
||||
["unknown woreda", { ...JIJIGA, woreda: "Example" }, /no MoR LOCALITY_DESC match/],
|
||||
];
|
||||
|
||||
it.each(cases)("%s fails with an actionable validation error", (_label, input, pattern) => {
|
||||
expect(() => resolveMorGeo(input, FIXTURE)).toThrow(MorGeoMappingError);
|
||||
expect(() => resolveMorGeo(input, FIXTURE)).toThrow(pattern);
|
||||
});
|
||||
|
||||
it("names the offending address in the message so the company record can be corrected", () => {
|
||||
expect(() => resolveMorGeo({ ...JIJIGA, woreda: "Example" }, FIXTURE)).toThrow(
|
||||
/country="Ethiopia", region="SOMALI", zone="FAAFAN ZONE", woreda="Example"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses an ambiguous locality instead of taking the first row", () => {
|
||||
const input = {
|
||||
country: "Ethiopia",
|
||||
region: "OROMIA",
|
||||
zone: "ILLUBABOR",
|
||||
woreda: "BURE",
|
||||
};
|
||||
expect(() => resolveMorGeo(input, FIXTURE)).toThrow(MorGeoMappingError);
|
||||
expect(() => resolveMorGeo(input, FIXTURE)).toThrow(/ambiguous/);
|
||||
// Both colliding codes are named, and neither is silently selected.
|
||||
expect(() => resolveMorGeo(input, FIXTURE)).toThrow(/691, 890/);
|
||||
expect(tryResolveMorGeo(input, FIXTURE)).toBeNull();
|
||||
});
|
||||
|
||||
it("fails loudly when the MoR master has not been generated yet", () => {
|
||||
expect(() => resolveMorGeo(JIJIGA, [])).toThrow(/MoR location master is empty/);
|
||||
});
|
||||
});
|
||||
|
||||
it("reproduces MoR's numeric values unchanged, as strings", () => {
|
||||
const codes = resolveMorGeo(JIJIGA, FIXTURE);
|
||||
expect(codes).toEqual({
|
||||
Country: "70",
|
||||
Region: "6",
|
||||
City: "31",
|
||||
Wereda: "190",
|
||||
});
|
||||
for (const value of Object.values(codes)) {
|
||||
expect(typeof value).toBe("string");
|
||||
expect(value).toMatch(/^[0-9]+$/);
|
||||
}
|
||||
// The source row is the only origin of every code — no renumbering, no derivation.
|
||||
const [countryNo, , parishNo, , cityNo, , localityNo] = FIXTURE[0];
|
||||
expect(codes).toEqual({
|
||||
Country: String(countryNo),
|
||||
Region: String(parishNo),
|
||||
City: String(cityNo),
|
||||
Wereda: String(localityNo),
|
||||
});
|
||||
});
|
||||
|
||||
it("never emits an Open Admin Data ETxx identifier", () => {
|
||||
for (const value of Object.values(resolveMorGeo(JIJIGA, FIXTURE))) {
|
||||
expect(value).not.toMatch(/^ET/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("treats a blank country as domestic, matching the column default", () => {
|
||||
expect(resolveMorGeo({ ...JIJIGA, country: "" }, FIXTURE).Country).toBe("70");
|
||||
expect(resolveMorGeo({ ...JIJIGA, country: null }, FIXTURE).Country).toBe("70");
|
||||
});
|
||||
|
||||
it("resolves a named foreign country rather than defaulting it to Ethiopia", () => {
|
||||
expect(
|
||||
resolveMorGeo(
|
||||
{
|
||||
country: "Djibouti",
|
||||
region: "DJIBOUTI",
|
||||
zone: "DJIBOUTI VILLE",
|
||||
woreda: "BALBALA",
|
||||
},
|
||||
FIXTURE,
|
||||
),
|
||||
).toEqual({ Country: "253", Region: "1", City: "1", Wereda: "1" });
|
||||
});
|
||||
|
||||
it("accepts a company record that already holds a MoR code, but only a real one", () => {
|
||||
expect(resolveMorGeo({ ...JIJIGA, region: "6" }, FIXTURE).Region).toBe("6");
|
||||
expect(() => resolveMorGeo({ ...JIJIGA, region: "999" }, FIXTURE)).toThrow(
|
||||
/no MoR PARISH_NAME match/,
|
||||
);
|
||||
});
|
||||
});
|
||||
255
apps/edr-freight-api/src/config/mor-location.resolver.ts
Normal file
255
apps/edr-freight-api/src/config/mor-location.resolver.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
|
||||
import { MOR_LOCATIONS, MorLocationTuple } from "./mor-locations.data";
|
||||
|
||||
/**
|
||||
* Resolves an EDR company address to the Ministry of Revenues' own EIMS location codes, using the
|
||||
* MoR location master (`EIMS_COUNTRY_REGION_VW`) shipped in `mor-locations.data.ts`.
|
||||
*
|
||||
* MoR's field names do not line up with either EDR's or generic Ethiopian administrative datasets,
|
||||
* so the mapping is fixed by the Ministry sheet, not by interpretation:
|
||||
*
|
||||
* Company.country -> COUNTRY_NAME -> COUNTRY_NO -> BuyerDetails.Country
|
||||
* Company.region -> PARISH_NAME -> PARISH_NO -> BuyerDetails.Region
|
||||
* Company.zone -> CITY_NAME -> CITY_NO -> BuyerDetails.City
|
||||
* Company.woreda -> LOCALITY_DESC -> LOCALITY_NO -> BuyerDetails.Wereda
|
||||
*
|
||||
* This replaces the previous `EIMS_BUYER_*_CODES` environment maps and the `ethiopia-geo-codes.ts`
|
||||
* table they layered over. Both invented their codes (sequential "01".."15" per region, from a
|
||||
* generic administrative CSV) and both looked names up **globally**, which cannot be correct:
|
||||
* KERSA, GORO, BABILE and BURE each occur in several different zones with different LOCALITY_NOs.
|
||||
* A global name lookup silently picked the first, i.e. filed a real invoice against whichever tax
|
||||
* jurisdiction happened to sort first. Resolution here is strictly hierarchical — each level is
|
||||
* searched only within the rows its parent already selected.
|
||||
*
|
||||
* Open Admin Data identifiers (`ET14`, `ET0407`, …) are unrelated to this code system and must
|
||||
* never appear in an EIMS payload; nothing in this module can emit one, since every returned value
|
||||
* comes from a numeric column of the Ministry sheet.
|
||||
*/
|
||||
|
||||
export interface MorGeoCodes {
|
||||
/** COUNTRY_NO as a string — `BuyerDetails.Country`. */
|
||||
Country: string;
|
||||
/** PARISH_NO as a string — `BuyerDetails.Region`. */
|
||||
Region: string;
|
||||
/** CITY_NO as a string — `BuyerDetails.City`. MoR calls the zone level "City". */
|
||||
City: string;
|
||||
/** LOCALITY_NO as a string — `BuyerDetails.Wereda`. */
|
||||
Wereda: string;
|
||||
}
|
||||
|
||||
export interface MorAddressInput {
|
||||
country?: string | null;
|
||||
region?: string | null;
|
||||
zone?: string | null;
|
||||
woreda?: string | null;
|
||||
}
|
||||
|
||||
type Level = "country" | "region" | "zone" | "woreda";
|
||||
|
||||
/** Which tuple slots hold the name and the code at each level. */
|
||||
const SLOTS: Record<Level, { name: 1 | 3 | 5 | 7; no: 0 | 2 | 4 | 6; column: string }> = {
|
||||
country: { name: 1, no: 0, column: "COUNTRY_NAME" },
|
||||
region: { name: 3, no: 2, column: "PARISH_NAME" },
|
||||
zone: { name: 5, no: 4, column: "CITY_NAME" },
|
||||
woreda: { name: 7, no: 6, column: "LOCALITY_DESC" },
|
||||
};
|
||||
|
||||
/**
|
||||
* One normalized form for both sides of every comparison. Deliberately conservative: it removes
|
||||
* differences that cannot change which jurisdiction is meant (case, stray and repeated whitespace,
|
||||
* hyphen/slash/parenthesis/apostrophe punctuation, combining accents) and nothing else. There is
|
||||
* no fuzzy or edit-distance matching anywhere in this module — a near-miss must fail loudly rather
|
||||
* than file an invoice against a neighbouring woreda.
|
||||
*
|
||||
* " FAAFAN ZONE " -> "FAAFAN ZONE"
|
||||
* "Wal-Mera" -> "WAL MERA"
|
||||
* "Ber'ano" -> "BERANO"
|
||||
* "ZONE 1 (AYSSAITA)"-> "ZONE 1 AYSSAITA"
|
||||
*/
|
||||
const normalizeCache = new Map<string, string>();
|
||||
export function normalizeName(value: string | null | undefined): string {
|
||||
const raw = value ?? "";
|
||||
const hit = normalizeCache.get(raw);
|
||||
if (hit !== undefined) return hit;
|
||||
const normalized = raw
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toUpperCase()
|
||||
.replace(/['\u2018\u2019`]/g, "")
|
||||
.replace(/[^A-Z0-9]+/g, " ")
|
||||
.trim();
|
||||
normalizeCache.set(raw, normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reviewed spelling differences between what EDR/e-Trade store and what the Ministry sheet calls
|
||||
* the same place. Every entry is scoped to the administrative level it applies to, and to its
|
||||
* parent where the name is not unique nationwide — so an alias can never reach across into another
|
||||
* region's jurisdiction. `from`/`to` are compared normalized, so casing and spacing here are
|
||||
* cosmetic.
|
||||
*
|
||||
* Add an entry only after confirming the two names are the same place in the Ministry sheet. This
|
||||
* is the only sanctioned place for spelling compatibility; `mor-locations.data.ts` stays verbatim.
|
||||
*/
|
||||
interface MorAlias {
|
||||
level: Exclude<Level, "country">;
|
||||
/** Parent scope, normalized-compared. Omit a level to leave the alias unscoped at that level. */
|
||||
region?: string;
|
||||
zone?: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
const ALIASES: MorAlias[] = [
|
||||
// e-Trade and the customer portal both spell the Somali zone "Fafen"; MoR spells it "FAAFAN
|
||||
// ZONE". Confirmed same zone (CITY_NO 31) — this is the buyer that first exposed the whole
|
||||
// fabricated-code problem.
|
||||
{ level: "zone", region: "SOMALI", from: "Fafen", to: "FAAFAN ZONE" },
|
||||
// MoR's own capital of that zone is "JIJIGA"; every other source spells it "Jigjiga".
|
||||
{
|
||||
level: "woreda",
|
||||
region: "SOMALI",
|
||||
zone: "FAAFAN ZONE",
|
||||
from: "Jigjiga",
|
||||
to: "JIJIGA",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* MoR suffixes many zone labels with " ZONE" ("JIMMA ZONE", "FAAFAN ZONE", "SITI ZONE") while EDR
|
||||
* stores the bare name. Retrying the suffixed spelling is an exact match against a second candidate
|
||||
* string, scoped to the already-resolved region — not fuzzy matching — and it removes a long tail
|
||||
* of otherwise hand-maintained aliases. Applied to the zone level only: locality suffixes
|
||||
* ("WOREDA", "TOWN ADMINISTRATION") are not mechanical and could select a different place.
|
||||
*/
|
||||
const zoneSuffixCandidates = (normalized: string): string[] =>
|
||||
normalized.endsWith(" ZONE") ? [] : [`${normalized} ZONE`];
|
||||
|
||||
export class MorGeoMappingError extends BadRequestException {
|
||||
constructor(code: "EIMS_GEO_MAPPING_FAILED" | "EIMS_GEO_AMBIGUOUS", message: string) {
|
||||
super({ code, message });
|
||||
}
|
||||
}
|
||||
|
||||
/** Renders the address being resolved for an error message. No customer-identifying data. */
|
||||
const describe = (input: MorAddressInput): string =>
|
||||
`country="${input.country ?? ""}", region="${input.region ?? ""}", ` +
|
||||
`zone="${input.zone ?? ""}", woreda="${input.woreda ?? ""}"`;
|
||||
|
||||
function matchLevel(
|
||||
rows: MorLocationTuple[],
|
||||
level: Level,
|
||||
raw: string | null | undefined,
|
||||
parents: { region?: string; zone?: string },
|
||||
input: MorAddressInput,
|
||||
): { no: number; rows: MorLocationTuple[] } {
|
||||
const { name: nameSlot, no: noSlot, column } = SLOTS[level];
|
||||
const wanted = normalizeName(raw);
|
||||
|
||||
const candidates: string[] = [];
|
||||
if (wanted) {
|
||||
candidates.push(wanted);
|
||||
for (const alias of ALIASES) {
|
||||
if (alias.level !== level) continue;
|
||||
if (alias.region && normalizeName(alias.region) !== parents.region) continue;
|
||||
if (alias.zone && normalizeName(alias.zone) !== parents.zone) continue;
|
||||
if (normalizeName(alias.from) === wanted) candidates.push(normalizeName(alias.to));
|
||||
}
|
||||
if (level === "zone") candidates.push(...zoneSuffixCandidates(wanted));
|
||||
}
|
||||
|
||||
let matched: MorLocationTuple[] = [];
|
||||
for (const candidate of candidates) {
|
||||
matched = rows.filter((row) => normalizeName(row[nameSlot] as string) === candidate);
|
||||
if (matched.length > 0) break;
|
||||
}
|
||||
|
||||
// A company record that already holds the MoR code itself resolves too — but only when that code
|
||||
// genuinely exists at this level under this parent. An unvalidated numeric pass-through is how a
|
||||
// wrong code reaches MoR without anything noticing.
|
||||
if (matched.length === 0 && /^[0-9]{1,6}$/.test((raw ?? "").trim())) {
|
||||
const asCode = Number((raw ?? "").trim());
|
||||
matched = rows.filter((row) => row[noSlot] === asCode);
|
||||
}
|
||||
|
||||
if (matched.length === 0) {
|
||||
throw new MorGeoMappingError(
|
||||
"EIMS_GEO_MAPPING_FAILED",
|
||||
`EIMS geographic mapping failed: no MoR ${column} match for ${describe(input)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const distinct = [...new Set(matched.map((row) => row[noSlot] as number))];
|
||||
if (distinct.length > 1) {
|
||||
throw new MorGeoMappingError(
|
||||
"EIMS_GEO_AMBIGUOUS",
|
||||
`EIMS geographic mapping is ambiguous: MoR ${column} "${(raw ?? "").trim()}" matches ` +
|
||||
`${distinct.length} different codes (${distinct.sort((a, b) => a - b).join(", ")}) for ` +
|
||||
`${describe(input)}. Correct the company address or the MoR reference data; an ambiguous ` +
|
||||
"location is never filed.",
|
||||
);
|
||||
}
|
||||
|
||||
return { no: distinct[0], rows: matched };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the full hierarchy, or throws a `BadRequestException` naming the level that failed.
|
||||
*
|
||||
* Never guesses and never returns a partial result: an unknown or ambiguous location must stop the
|
||||
* filing here, locally, before any MoR request and before an EIMS counter is consumed.
|
||||
*/
|
||||
export function resolveMorGeo(
|
||||
input: MorAddressInput,
|
||||
rows: MorLocationTuple[] = MOR_LOCATIONS,
|
||||
): MorGeoCodes {
|
||||
if (rows.length === 0) {
|
||||
throw new MorGeoMappingError(
|
||||
"EIMS_GEO_MAPPING_FAILED",
|
||||
"EIMS geographic mapping failed: the MoR location master is empty. Generate it with " +
|
||||
"`pnpm --filter @edr/freight-api eims:import-locations <workbook.xlsx>`.",
|
||||
);
|
||||
}
|
||||
|
||||
// `companies.country` defaults to 'Ethiopia' and is often left blank on older rows; blank means
|
||||
// domestic here, exactly as the column default says. A *named* foreign country is resolved like
|
||||
// any other and fails if MoR does not list it — it is never quietly filed as Ethiopia.
|
||||
const country = (input.country ?? "").trim() || "Ethiopia";
|
||||
|
||||
const inCountry = matchLevel(rows, "country", country, {}, input);
|
||||
const inRegion = matchLevel(inCountry.rows, "region", input.region, {}, input);
|
||||
const regionScope = normalizeName(inRegion.rows[0][SLOTS.region.name] as string);
|
||||
const inZone = matchLevel(inRegion.rows, "zone", input.zone, { region: regionScope }, input);
|
||||
const zoneScope = normalizeName(inZone.rows[0][SLOTS.zone.name] as string);
|
||||
const inWoreda = matchLevel(
|
||||
inZone.rows,
|
||||
"woreda",
|
||||
input.woreda,
|
||||
{
|
||||
region: regionScope,
|
||||
zone: zoneScope,
|
||||
},
|
||||
input,
|
||||
);
|
||||
|
||||
return {
|
||||
Country: String(inCountry.no),
|
||||
Region: String(inRegion.no),
|
||||
City: String(inZone.no),
|
||||
Wereda: String(inWoreda.no),
|
||||
};
|
||||
}
|
||||
|
||||
/** Non-throwing variant for callers that already have a working fallback (the seller identity). */
|
||||
export function tryResolveMorGeo(
|
||||
input: MorAddressInput,
|
||||
rows: MorLocationTuple[] = MOR_LOCATIONS,
|
||||
): MorGeoCodes | null {
|
||||
try {
|
||||
return resolveMorGeo(input, rows);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
18
apps/edr-freight-api/src/config/mor-locations.data.ts
Normal file
18
apps/edr-freight-api/src/config/mor-locations.data.ts
Normal 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[] = [];
|
||||
Reference in New Issue
Block a user