mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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.
276 lines
9.7 KiB
TypeScript
276 lines
9.7 KiB
TypeScript
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/,
|
|
);
|
|
});
|
|
});
|