Merge pull request #1405 from Tria-plc/credit-invoice

feat(eims): resolve buyer geography from the MoR location master
This commit is contained in:
Hagernesh Tadesse
2026-08-24 10:09:19 +03:00
committed by GitHub
25 changed files with 1150 additions and 566 deletions

View File

@@ -202,14 +202,13 @@ EIMS_NATURE_OF_SUPPLIES=service
EIMS_PAYMENT_MODE=CASH
EIMS_PAYMENT_TERM=IMMIDIATE
EIMS_UNIT_DEFAULT=PCS
# MoR numeric country code for the buyer; our companies store the country name.
EIMS_BUYER_COUNTRY_CODE=
# Buyer region name -> MoR numeric code. companies.region holds names; MoR wants ^[0-9]{1,3}$.
# An unmapped region fails locally rather than being filed with a guess.
EIMS_BUYER_REGION_CODES=Addis Ababa=13
# Same mechanism for Wereda. MoR has never named a Wereda regex in an error (only Region's is
# confirmed), so this is precautionary — but an unmapped name still fails locally, not filed as a guess.
EIMS_BUYER_WEREDA_CODES=
# Buyer Country/Region/City/Wereda are NOT configured here any more. They are resolved from the
# Ministry's own location master (EIMS_COUNTRY_REGION_VW), committed as
# src/config/mor-locations.data.ts and regenerated with:
# pnpm --filter @edr/freight-api eims:import-locations <workbook.xlsx>
# The removed EIMS_BUYER_COUNTRY_CODE / _COUNTRY_CODES / _REGION_CODES / _CITY_CODES /
# _WEREDA_CODES maps are ignored if still set — MoR reference data is the only source, and an env
# var must not be able to override an official code. Delete them from your deployment config.
EIMS_CASHIER_NAME=
EIMS_SALESPERSON_NAME=
# Automatic filing of issued invoices (@Cron sweep, one invoice per tick).

View File

@@ -38,7 +38,8 @@
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
"migration:run": "nest build && node dist/scripts/migrate.js",
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts",
"eims:login": "ts-node -r tsconfig-paths/register src/scripts/eims-login.ts"
"eims:login": "ts-node -r tsconfig-paths/register src/scripts/eims-login.ts",
"eims:import-locations": "ts-node -r tsconfig-paths/register src/scripts/import-mor-locations.ts"
},
"dependencies": {
"@edr/api-common": "workspace:*",

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[] = [];

View File

@@ -60,11 +60,7 @@ const context = (over: Partial<EimsMapperContext> = {}): EimsMapperContext => ({
unitDefault: "PCS",
incomeWithholdValue: 0,
transactionWithholdValue: 0,
buyerCountryCode: "231", // test-only, not a confirmed real MoR code
buyerCountryCodes: {},
buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: {},
buyerCityCodes: {},
buyerGeo: { Country: "70", Region: "6", City: "31", Wereda: "190" },
...over,
});
@@ -94,10 +90,9 @@ describe("toEimsInvoice", () => {
const doc = toEimsInvoice(invoice(), seller, context());
expect(doc.BuyerDetails).toEqual({
City: null,
// company.country is "Ethiopia" (the domestic default) — resolves to context's flat
// buyerCountryCode fallback, not null, per resolveCountryCode.
Country: "231",
// Resolved by the registration service before the counter was reserved; the mapper copies.
City: "31",
Country: "70",
Email: "buyer@abc.et",
HouseNumber: "NEW",
IdNumber: null,
@@ -105,11 +100,11 @@ describe("toEimsInvoice", () => {
Tin: "0999930000",
LegalName: "ABC Trading PLC",
Phone: "0912345678",
Region: "13",
Region: "6",
Zone: "SHA",
Kebele: "03",
VatNumber: "123475885858",
Wereda: "574",
Wereda: "190",
});
});
@@ -284,108 +279,39 @@ describe("toEimsInvoice", () => {
});
describe("toEimsInvoice — MoR field constraints", () => {
it("passes a buyer region through when it is already a MoR code", () => {
/**
* Geography is no longer resolved here. `resolveMorGeo` runs in the registration service, ahead
* of the counter reservation, and hands the mapper finished MoR codes — so what these cover is
* that the resolved values reach the right `BuyerDetails` fields untouched. The lookup rules
* themselves (hierarchy, aliases, ambiguity) are covered in `mor-location.resolver.spec.ts`.
*/
it("puts the resolved MoR codes on BuyerDetails, unmodified and as strings", () => {
const doc = toEimsInvoice(invoice(), seller, context());
expect(doc.BuyerDetails.Region).toBe("13");
expect(doc.BuyerDetails.Country).toBe("70");
expect(doc.BuyerDetails.Region).toBe("6");
expect(doc.BuyerDetails.City).toBe("31");
expect(doc.BuyerDetails.Wereda).toBe("190");
for (const field of ["Country", "Region", "City", "Wereda"] as const) {
expect(typeof doc.BuyerDetails[field]).toBe("string");
}
});
it("maps a region name to its code, ignoring case and spacing", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, region: " addis ababa " } }),
seller,
context({ buyerRegionCodes: { "Addis Ababa": "13" } }),
);
expect(doc.BuyerDetails.Region).toBe("13");
});
it("refuses to file a buyer whose region has no mapping", () => {
expect(() =>
toEimsInvoice(
invoice({ company: { ...invoice().company!, region: "Somewhere Else" } }),
seller,
context(),
),
).toThrow(/not a MoR Region code and has no mapping/);
});
it("refuses a buyer with no region at all rather than guessing one", () => {
expect(() =>
toEimsInvoice(
invoice({ company: { ...invoice().company!, region: null } }),
seller,
context(),
),
).toThrow(/buyer Region \(unset\)/);
});
it("passes a buyer wereda through when it is already a MoR code", () => {
it("never emits an Open Admin Data ETxx identifier as a location", () => {
const doc = toEimsInvoice(invoice(), seller, context());
expect(doc.BuyerDetails.Wereda).toBe("574");
for (const field of ["Country", "Region", "City", "Wereda"] as const) {
expect(doc.BuyerDetails[field]).toMatch(/^[0-9]+$/);
}
});
it("maps a wereda name to its code", () => {
it("keeps BuyerDetails.Zone as the buyer's own zone name — MoR takes that one as prose", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, woreda: "Yeka" } }),
invoice({ company: { ...invoice().company!, zone: "Fafen" } }),
seller,
context({ buyerWeredaCodes: { Yeka: "99" } }),
context(),
);
expect(doc.BuyerDetails.Wereda).toBe("99");
});
it("refuses to file a buyer whose wereda has no mapping", () => {
expect(() =>
toEimsInvoice(
invoice({ company: { ...invoice().company!, woreda: "Yeka" } }),
seller,
context({ buyerWeredaCodes: {} }),
),
).toThrow(/buyer Wereda "Yeka".*EIMS_BUYER_WEREDA_CODES/);
});
it("derives City from the buyer's zone via the city code map", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, zone: "Kirkos" } }),
seller,
context({ buyerCityCodes: { Kirkos: "101" } }),
);
expect(doc.BuyerDetails.City).toBe("101");
});
it("leaves City null (not a throw) when the buyer's zone has no city mapping — City is optional", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, zone: "Somewhere Else" } }),
seller,
context({ buyerCityCodes: {} }),
);
expect(doc.BuyerDetails.City).toBeNull();
});
it("maps a buyer country name to its code via the country code map", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, country: "Djibouti" } }),
seller,
context({ buyerCountryCodes: { Djibouti: "071" } }),
);
expect(doc.BuyerDetails.Country).toBe("071");
});
it("falls back to the flat domestic country code only for Ethiopia, not any unmapped country", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, country: "Ethiopia" } }),
seller,
context({ buyerCountryCode: "231", buyerCountryCodes: {} }),
);
expect(doc.BuyerDetails.Country).toBe("231");
});
it("refuses a genuinely foreign buyer country with no mapping — never silently files it as Ethiopia", () => {
expect(() =>
toEimsInvoice(
invoice({ company: { ...invoice().company!, country: "Kenya" } }),
seller,
context({ buyerCountryCode: "231", buyerCountryCodes: {} }),
),
).toThrow(/buyer Country "Kenya".*EIMS_BUYER_COUNTRY_CODES/);
expect(doc.BuyerDetails.Zone).toBe("Fafen");
expect(doc.BuyerDetails.City).toBe("31");
});
it("emits NatureOfSupplies lowercase, whatever case it was configured in", () => {

View File

@@ -15,6 +15,7 @@
* authoritative: they are passed through or overridable rather than validated against a fixed set.
*/
import { MorGeoCodes } from "../../config/mor-location.resolver";
import { round2 } from "./invoice-settlement.util";
/** Only proven-required constant: the 400 SCHEMA ERROR sample rejects a payload without it. */
@@ -235,35 +236,15 @@ export interface EimsMapperContext {
*/
relatedDocument?: string | null;
/**
* Domestic fallback only, applied when `company.country` is empty or "Ethiopia" and not already
* in `buyerCountryCodes` — see that field. Never applied to a genuinely foreign buyer.
*/
buyerCountryCode?: string | null;
/** Country name → MoR code. Format unconfirmed, so looked up by name only, not digit-validated. */
buyerCountryCodes: Record<string, string>;
/**
* Region name → MoR numeric code, for buyers whose stored region is free text.
* The buyer's MoR location codes — `Country`/`Region`/`City`/`Wereda`, already resolved from the
* Ministry's location master by `resolveMorGeo`.
*
* `companies.region` holds names ("Addis Ababa") while MoR validates `BuyerDetails.Region`
* against `^[0-9]{1,3}$`. A stored value that is already a code passes through; anything else
* must be in this map or the mapping **fails locally** — sending a guessed region code onto a
* tax document is worse than refusing to file.
* Resolved by the caller, not here, and deliberately so: geographic resolution can fail (unknown
* or ambiguous address) and that failure must happen **before** an EIMS counter is reserved, so a
* bad company address never burns a sequence number. See `mor-location.resolver.ts` for why the
* lookup has to be hierarchical, and `EimsInvoiceRegistrationService` for where it runs.
*/
buyerRegionCodes: Record<string, string>;
/**
* Wereda name → MoR code, same shape as `buyerRegionCodes`. `companies.woreda` holds names
* ("Yeka") or codes inconsistently; unlike Region, MoR has never named a Wereda regex in an
* error, so this is precautionary rather than confirmed — but the fix is identical either way:
* fail locally on an unmapped name rather than file a guess.
*/
buyerWeredaCodes: Record<string, string>;
/**
* Buyer *zone* name → MoR City code. `Company` has no dedicated city column; Zone is the
* closest match in EDR's own data. Unlike Region/Wereda, City is optional — MoR has already
* accepted a live filing with it null — so an unmapped zone resolves to null, it does not fail
* the mapping.
*/
buyerCityCodes: Record<string, string>;
buyerGeo: MorGeoCodes;
buyerIdType?: string | null;
buyerIdNumber?: string | null;
/** Required when the invoice currency is not ETB. */
@@ -273,14 +254,6 @@ export interface EimsMapperContext {
formatDate?: (issuedAt: Date) => string;
}
/**
* MoR's own constraint on `Region`: one to three digits, confirmed by its 400 SCHEMA ERROR. Reused
* as the pass-through test for `Wereda` too — every Wereda value MoR has actually shown us (seller
* "12"/"13", the collection's "574") fits the same shape, though MoR has not named a Wereda regex
* the way it named Region's.
*/
const LOCATION_CODE = /^[0-9]{1,3}$/;
/**
* The only two values MoR accepts for `NatureOfSupplies`, lowercase.
*
@@ -309,87 +282,6 @@ export const formatEimsDate = (issuedAt: Date): string =>
* an unissued invoice, unresolved line tax, a line/total mismatch, or a non-ETB invoice with no
* exchange rate.
*/
/**
* A buyer's location value (Region, Wereda or City) as a MoR code: passed through when already
* numeric, otherwise looked up by name (case- and space-insensitive).
*
* Region/Wereda are required: an unmapped value throws — sending a guessed code onto a tax
* document is worse than refusing to file. City is optional (`required: false`, City's own
* caller) — MoR has already accepted a live filing with it null, so an unmapped zone resolves to
* null instead of blocking the invoice.
*/
function resolveLocationCode(
field: "Region" | "Wereda" | "City",
value: string | null | undefined,
codes: Record<string, string>,
envVar: string,
invoiceNumber: string,
opts: { required?: boolean } = {},
): string | null {
const raw = (value ?? "").trim();
if (LOCATION_CODE.test(raw)) return raw;
const key = raw.toLowerCase().replace(/\s+/g, " ");
const mapped = Object.entries(codes).find(
([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key,
)?.[1];
if (mapped && LOCATION_CODE.test(mapped)) return mapped;
if (opts.required === false) return null;
throw new Error(
`EIMS mapping: invoice ${invoiceNumber} has buyer ${field} ${raw ? `"${raw}"` : "(unset)"}, ` +
`which is not a MoR ${field} code and has no mapping. Add it to ${envVar}.`,
);
}
/**
* A buyer's `Country` as a MoR code: looked up by name in `codes` first; when unmapped, applies
* `domesticFallback` only if the stored country is empty or "Ethiopia" (the DB column's default).
* A genuinely foreign, unmapped country throws rather than silently filing as Ethiopia — same
* "fail locally, don't guess" rule as `resolveLocationCode`, but never digit-validated: MoR's
* Country code format is unconfirmed, unlike Region/Wereda's proven `^[0-9]{1,3}$`.
*/
function resolveCountryCode(
country: string | null | undefined,
codes: Record<string, string>,
domesticFallback: string | null,
invoiceNumber: string,
): string | null {
const raw = (country ?? "").trim();
const key = raw.toLowerCase().replace(/\s+/g, " ");
const mapped = Object.entries(codes).find(
([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key,
)?.[1];
if (mapped) return mapped;
if ((!raw || key === "ethiopia") && domesticFallback) return domesticFallback;
throw new Error(
`EIMS mapping: invoice ${invoiceNumber} has buyer Country "${raw || "(unset)"}", which has no ` +
"MoR country code mapping. Add it to EIMS_BUYER_COUNTRY_CODES.",
);
}
/**
* Same name-or-code resolution as `resolveLocationCode`, for a caller with no invoice to attach an
* error to and that must never throw — currently only `EimsSellerCacheService`, resolving
* e-Trade's region/zone/woreda *names* for EDR's own seller identity. Pass-through numeric code,
* name lookup, `undefined` on no match — the caller falls back to static config either way.
*/
export function resolveOptionalCode(
value: string | null | undefined,
codes: Record<string, string>,
): string | undefined {
const raw = (value ?? "").trim();
if (LOCATION_CODE.test(raw)) return raw;
const key = raw.toLowerCase().replace(/\s+/g, " ");
const mapped = Object.entries(codes).find(
([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key,
)?.[1];
return mapped && LOCATION_CODE.test(mapped) ? mapped : undefined;
}
export function toEimsInvoice(
invoice: EimsMapperInvoice,
seller: EimsSellerDetails,
@@ -511,16 +403,11 @@ export function toEimsInvoice(
return {
BuyerDetails: {
// No dedicated city column on Company — Zone is the closest match; optional (see
// resolveLocationCode's City comment).
City: resolveLocationCode(
"City",
company.zone,
context.buyerCityCodes,
"EIMS_BUYER_CITY_CODES",
invoice.invoiceNumber,
{ required: false },
),
// Country/Region/City/Wereda are MoR location codes resolved from the Ministry's own
// location master *before* this mapper ran, and before an EIMS counter was reserved — see
// EimsMapperContext.buyerGeo. `Zone` alongside them is the buyer's free-text zone name,
// which MoR takes as prose, not a code.
City: context.buyerGeo.City,
Email: company.email ?? null,
HouseNumber: company.houseNo ?? null,
IdNumber: context.buyerIdNumber ?? null,
@@ -528,29 +415,12 @@ export function toEimsInvoice(
Tin: company.tin,
LegalName: company.name,
Phone: company.phone ?? null,
Region: resolveLocationCode(
"Region",
company.region,
context.buyerRegionCodes,
"EIMS_BUYER_REGION_CODES",
invoice.invoiceNumber,
),
Country: resolveCountryCode(
company.country,
context.buyerCountryCodes,
context.buyerCountryCode ?? null,
invoice.invoiceNumber,
),
Region: context.buyerGeo.Region,
Country: context.buyerGeo.Country,
Zone: company.zone ?? null,
Kebele: company.kebele ?? null,
VatNumber: company.vatNumber ?? null,
Wereda: resolveLocationCode(
"Wereda",
company.woreda,
context.buyerWeredaCodes,
"EIMS_BUYER_WEREDA_CODES",
invoice.invoiceNumber,
),
Wereda: context.buyerGeo.Wereda,
},
DocumentDetails: {
DocumentNumber: context.documentNumber,

View File

@@ -36,9 +36,12 @@ const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
tin: "0999930000",
vatNumber: "123475885858",
phone: "0912345678",
region: "13",
zone: "SHA",
woreda: "574",
// A real MoR address — Ethiopia / SOMALI / FAAFAN ZONE / JIJIGA, which the Ministry master
// codes as 70 / 6 / 31 / 190. Spelled the way EDR and e-Trade actually store it, so the
// alias layer is exercised end to end rather than only in the resolver's own spec.
region: "Somali",
zone: "Fafen",
woreda: "Jigjiga",
kebele: "03",
houseNo: "NEW",
country: "Ethiopia",

View File

@@ -7,6 +7,7 @@ import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialE
import { EimsConfig } from "../../config/eims.config";
import { Invoice } from "../billing/entities/invoice.entity";
import { MorGeoCodes, resolveMorGeo } from "../../config/mor-location.resolver";
import { EimsDocumentType, EimsMapperLine, toEimsInvoice } from "../billing/eims-invoice.mapper";
import { sendCompanyChannels } from "../notifications/notify-company.util";
import { NotificationsService } from "../notifications/notifications.service";
@@ -32,6 +33,8 @@ interface BulkReservation {
invoice: Invoice & { lines: EimsMapperLine[] };
documentType: EimsDocumentType;
relatedDocument: string | null;
/** Resolved before this reservation existed — see the `prepared` pass in `bulkRegister`. */
buyerGeo: MorGeoCodes;
invoiceCounter: number;
documentNumber: string;
previousIrn: string;
@@ -123,7 +126,16 @@ export class EimsBulkRegistrationService {
}
relatedDocument = invoice.relatedInvoice.eimsIrn;
}
return { invoice, documentType, relatedDocument };
// Same rule as the single-invoice path: buyer geography is resolved from the MoR location
// master before reserveBulk touches a counter, so one bad company address fails the whole
// batch locally instead of burning a block of EIMS sequence numbers.
const buyerGeo = resolveMorGeo({
country: invoice.company?.country,
region: invoice.company?.region,
zone: invoice.company?.zone,
woreda: invoice.company?.woreda,
});
return { invoice, documentType, relatedDocument, buyerGeo };
});
if (prepared.length === 0) {
@@ -141,6 +153,7 @@ export class EimsBulkRegistrationService {
r.invoice,
this.sellerCache.getSellerDetails(cfg),
buildEimsContext(cfg, {
buyerGeo: r.buyerGeo,
documentNumber: r.documentNumber,
invoiceCounter: r.invoiceCounter,
previousIrn: r.previousIrn,
@@ -269,7 +282,12 @@ export class EimsBulkRegistrationService {
/** TX1. Reserve a contiguous block of N counters, one per invoice, in the given order. */
private async reserveBulk(
prepared: Array<{ invoice: Invoice & { lines: EimsMapperLine[] }; documentType: EimsDocumentType; relatedDocument: string | null }>,
prepared: Array<{
invoice: Invoice & { lines: EimsMapperLine[] };
documentType: EimsDocumentType;
relatedDocument: string | null;
buyerGeo: MorGeoCodes;
}>,
systemNumber: string,
placeholder: string,
): Promise<BulkReservation[]> {
@@ -302,7 +320,7 @@ export class EimsBulkRegistrationService {
// Locked in the caller's given order — stable, avoids two concurrent bulk calls deadlocking
// on the opposite lock order.
for (const { invoice, documentType, relatedDocument } of prepared) {
for (const { invoice, documentType, relatedDocument, buyerGeo } of prepared) {
const locked = await this.lockInvoice(manager, invoice.id);
const thisCounter = counter++;
const thisDocNumber = String(docNumber++);
@@ -322,6 +340,7 @@ export class EimsBulkRegistrationService {
invoice: Object.assign(locked, { lines: invoice.lines }),
documentType,
relatedDocument,
buyerGeo,
invoiceCounter: thisCounter,
documentNumber: thisDocNumber,
previousIrn: thisPreviousIrn,

View File

@@ -66,7 +66,13 @@ describe("assertEimsInvoiceConfig — charge-type overrides", () => {
});
describe("buildEimsContext — taxForLine", () => {
const input = { documentNumber: "24", invoiceCounter: 7, previousIrn: "", session: SESSION };
const input = {
buyerGeo: { Country: "70", Region: "6", City: "31", Wereda: "190" },
documentNumber: "24",
invoiceCounter: 7,
previousIrn: "",
session: SESSION,
};
const line = (chargeType: string) => ({ chargeType, quantity: 1, unitRate: 100, amount: 100 });
it("uses the per-chargeType override when one is configured", () => {

View File

@@ -1,5 +1,6 @@
import { BadRequestException } from "@nestjs/common";
import { EimsConfig } from "../../config/eims.config";
import { MorGeoCodes } from "../../config/mor-location.resolver";
import { EimsSessionContext } from "./eims-auth.service";
import {
EimsMapperContext,
@@ -149,6 +150,12 @@ export function buildEimsSeller(config: EimsConfig): EimsSellerDetails {
}
export interface EimsContextInput {
/**
* The buyer's MoR location codes, resolved from the Ministry location master by
* `resolveMorGeo` **before** the caller reserved an EIMS counter — see
* `EimsMapperContext.buyerGeo`.
*/
buyerGeo: MorGeoCodes;
/** `DocumentDetails.DocumentNumber`. The caller decides its source. */
documentNumber: string;
invoiceCounter: number;
@@ -205,11 +212,7 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
unitDefault: invoice.unitDefault,
incomeWithholdValue: invoice.incomeWithholdValue!,
transactionWithholdValue: invoice.transactionWithholdValue!,
buyerCountryCode: invoice.buyerCountryCode,
buyerCountryCodes: invoice.buyerCountryCodes,
buyerRegionCodes: invoice.buyerRegionCodes,
buyerWeredaCodes: invoice.buyerWeredaCodes,
buyerCityCodes: invoice.buyerCityCodes,
buyerGeo: input.buyerGeo,
// TEMPORARY — see EimsInvoiceConfig.buyerIdType.
buyerIdType: invoice.buyerIdType,
buyerIdNumber: invoice.buyerIdNumber,

View File

@@ -13,6 +13,7 @@ import { EimsClientService } from "./eims-client.service";
import { EimsApiException, EimsConfigException } from "./eims.errors";
import { buildEimsSeller } from "./eims-invoice-context";
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
import { ETradeService } from "../companies/services/etrade.service";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
import { EimsSystemState } from "./entities/eims-system-state.entity";
import { EimsInvoiceStatus } from "./eims-registration.types";
@@ -58,9 +59,12 @@ const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
vatNumber: "123475885858",
phone: "0912345678",
email: "buyer@abc.et",
region: "13",
zone: "SHA",
woreda: "574",
// A real MoR address — Ethiopia / SOMALI / FAAFAN ZONE / JIJIGA, which the Ministry master
// codes as 70 / 6 / 31 / 190. Spelled the way EDR and e-Trade actually store it, so the
// alias layer is exercised end to end rather than only in the resolver's own spec.
region: "Somali",
zone: "Fafen",
woreda: "Jigjiga",
kebele: "03",
houseNo: "NEW",
country: "Ethiopia",
@@ -502,21 +506,23 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
});
});
it("a mapper failure after reservation (e.g. unmapped buyer country) also releases the reservation", async () => {
it("a mapper failure after reservation still releases the reservation", async () => {
// Regression: toEimsInvoice/buildEimsContext used to sit outside the try/catch that calls
// settleFailure — a throw here left the reservation permanently orphaned (a real live incident:
// 500 on register, then every subsequent attempt 409'd "already in flight" until manually
// resolved). This never reaches postSigned at all — the mapper throws before submit() is called.
const db = new FakeDb([
invoiceRow({ company: { ...invoiceRow().company, country: "France" } as never }),
]);
//
// The trigger used to be an unmapped buyer country. That can no longer get this far: geography
// is resolved before the reservation now (see the test below). A line/total mismatch is a
// mapper-only failure that still reaches this point.
const db = new FakeDb([invoiceRow({ totalAmount: 999999 })]);
const postSigned = jest.fn();
// The mapper throws a plain Error (it's a pure function, not a NestJS layer) — that's the
// point: settleFailure must treat *any* non-EimsApiException as pre-wire, not just its own
// known exception types.
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow(
/no MoR country code mapping/,
/lines sum to/,
);
expect(postSigned).not.toHaveBeenCalled();
@@ -532,6 +538,69 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
});
});
it("an unmappable buyer address fails before a counter is ever reserved", async () => {
// The whole point of resolving geography ahead of reserve(): a company-record problem is a
// local data problem, and it must not cost an EIMS sequence number. Nothing about the invoice
// or the system state may change.
const db = new FakeDb([
invoiceRow({ company: { ...invoiceRow().company, woreda: "Nowhere" } as never }),
]);
const before = { ...db.state };
const postSigned = jest.fn();
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow(
/no MoR LOCALITY_DESC match/,
);
expect(postSigned).not.toHaveBeenCalled();
expect(db.state).toMatchObject({
nextInvoiceCounter: before.nextInvoiceCounter,
nextDocumentNumber: before.nextDocumentNumber,
inFlightInvoiceId: null,
});
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
eimsStatus: EimsInvoiceStatus.NotSubmitted,
eimsInvoiceCounter: null,
});
});
it("files the buyer's MoR codes, resolved from the location master with no e-Trade call", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "IRN-1" } });
// The real seller cache, wired to an e-Trade mock that must never be reached: registration
// reads the company row EDR already stored, so filing stays deterministic and independent of
// e-Trade's availability. `refresh()` is deliberately not called — the cache stays empty and
// the seller falls back to static config, exactly as it does on a cold process.
const cfg = config();
const resolveCompanyData = jest.fn();
const sellerCache = new EimsSellerCacheService(
{ resolveCompanyData, extractRegistrationData: jest.fn() } as unknown as ETradeService,
{ get: () => cfg } as unknown as ConfigService,
);
const service = new EimsInvoiceRegistrationService(
db.asDataSource(),
{ get: () => cfg } as unknown as ConfigService,
{ postSigned, postBearer: jest.fn() } as unknown as EimsClientService,
{ getSessionContext: jest.fn().mockResolvedValue(SESSION) } as unknown as EimsAuthService,
{ notify: jest.fn().mockResolvedValue(undefined) } as unknown as NotificationInboxService,
{ directSend: jest.fn().mockResolvedValue(undefined) } as unknown as NotificationsService,
sellerCache,
);
await service.registerInvoiceWithEims(INVOICE_ID);
const [, body] = postSigned.mock.calls[0];
expect(body.BuyerDetails).toMatchObject({
Country: "70",
Region: "6",
City: "31",
Wereda: "190",
});
expect(resolveCompanyData).not.toHaveBeenCalled();
});
it("treats a success response with no IRN as a failed registration", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "" } });

View File

@@ -29,6 +29,7 @@ import { EimsClientService } from "./eims-client.service";
import { EimsApiException, EimsConfigException } from "./eims.errors";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
import { EimsSystemState } from "./entities/eims-system-state.entity";
import { resolveMorGeo } from "../../config/mor-location.resolver";
import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context";
import {
EimsInvoiceError,
@@ -116,6 +117,17 @@ export class EimsInvoiceRegistrationService {
relatedDocument = invoice.relatedInvoice.eimsIrn;
}
// Buyer geography is resolved from the MoR location master *here*, ahead of the reservation:
// an unknown or ambiguous company address is a local data problem, and failing it after
// reserving would consume an EIMS sequence number for an invoice that was never filable. It
// needs no network access, so there is no reason for it to sit behind the login either.
const buyerGeo = resolveMorGeo({
country: invoice.company?.country,
region: invoice.company?.region,
zone: invoice.company?.zone,
woreda: invoice.company?.woreda,
});
// Authenticate before reserving: the source system comes from the token, and the state row is
// keyed by it. A login failure here costs nothing — no counter has been consumed yet.
const session = await this.auth.getSessionContext();
@@ -135,6 +147,7 @@ export class EimsInvoiceRegistrationService {
invoice,
this.sellerCache.getSellerDetails(cfg),
buildEimsContext(cfg, {
buyerGeo,
// Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber
// against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy.
documentNumber: reservation.documentNumber,

View File

@@ -5,11 +5,13 @@ import { ETradeService } from "../companies/services/etrade.service";
import { eimsConfig, eimsInvoiceConfig } from "./eims-test-fixtures";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
// A real MoR address (PARISH_NO 13 / CITY_NO 78 / LOCALITY_NO 1100) — the resolver now works off
// the Ministry's own hierarchy, so a made-up address would simply not resolve.
const registrationData = (over: Record<string, unknown> = {}) => ({
companyName: "Ethio-Djibouti Railway PLC (eTrade)",
region: "Addis Ababa",
zone: "Bole",
woreda: "Yeka",
woreda: "Woreda 1",
mobilePhone: "0911000000",
regularPhone: "",
...over,
@@ -24,16 +26,10 @@ const build = (cfg: EimsConfig = eimsConfig()) => {
return { service, resolveCompanyData, extractRegistrationData, cfg };
};
const CODES = {
buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: { Yeka: "99" },
buyerCityCodes: { Bole: "101" },
};
describe("EimsSellerCacheService.getSellerDetails", () => {
it("static config wins over a conflicting e-Trade value", async () => {
const cfg = eimsConfig({
invoice: eimsInvoiceConfig({ sellerLegalName: "Ethio-Djibouti Railway S.C.", ...CODES }),
invoice: eimsInvoiceConfig({ sellerLegalName: "Ethio-Djibouti Railway S.C." }),
});
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValue({
@@ -56,7 +52,6 @@ describe("EimsSellerCacheService.getSellerDetails", () => {
sellerRegion: "",
sellerWereda: "",
sellerCity: null,
...CODES,
}),
});
const { service, resolveCompanyData } = build(cfg);
@@ -67,13 +62,32 @@ describe("EimsSellerCacheService.getSellerDetails", () => {
expect(seller.LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
expect(seller.Region).toBe("13");
expect(seller.Wereda).toBe("99");
expect(seller.City).toBe("78");
expect(seller.Wereda).toBe("1100");
});
it("leaves the static seller values alone when MoR does not list the e-Trade address", async () => {
// e-Trade's free text does not always correspond to a MoR row (here "Yeka" is a MoR *City*
// under ADDIS ABABA, not a locality under BOLE). That must degrade to the static config, which
// MoR has already cleared under rule 7017 — never throw, and never file a guessed code.
const cfg = eimsConfig({
invoice: eimsInvoiceConfig({ sellerRegion: "1", sellerWereda: "13", sellerCity: "101" }),
});
const { service, resolveCompanyData, extractRegistrationData } = build(cfg);
extractRegistrationData.mockReturnValue(registrationData({ woreda: "Yeka" }));
resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} });
await expect(service.refresh()).resolves.toBeUndefined();
const seller = service.getSellerDetails(cfg);
expect(seller.Region).toBe("1");
expect(seller.Wereda).toBe("13");
expect(seller.City).toBe("101");
});
it("VatNumber and Email are always the static value, never touched by e-Trade", async () => {
const cfg = eimsConfig({
invoice: eimsInvoiceConfig({ sellerVatNumber: "0000000000", sellerEmail: "finance@example.et", ...CODES }),
invoice: eimsInvoiceConfig({ sellerVatNumber: "0000000000", sellerEmail: "finance@example.et" }),
});
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} });
@@ -108,7 +122,7 @@ describe("EimsSellerCacheService.getSellerDetails", () => {
describe("EimsSellerCacheService.refresh", () => {
it("keeps the previous snapshot when a refresh fails", async () => {
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) });
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "" }) });
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} });
await service.refresh();
@@ -123,7 +137,7 @@ describe("EimsSellerCacheService.refresh", () => {
it("keeps the previous snapshot on timeout, without waiting for the slow request", async () => {
jest.useFakeTimers();
try {
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) });
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "" }) });
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} });
await service.refresh();

View File

@@ -3,7 +3,8 @@ import { ConfigService } from "@nestjs/config";
import { EimsConfig } from "../../config/eims.config";
import { ETradeService } from "../companies/services/etrade.service";
import { EimsSellerDetails, resolveOptionalCode } from "../billing/eims-invoice.mapper";
import { tryResolveMorGeo } from "../../config/mor-location.resolver";
import { EimsSellerDetails } from "../billing/eims-invoice.mapper";
import { buildEimsSeller } from "./eims-invoice-context";
const has = (value: string | null | undefined): value is string => Boolean(value && value.trim());
@@ -104,17 +105,24 @@ export class EimsSellerCacheService implements OnModuleInit {
);
if (!businessInfo) return; // no licence on file yet — keep the previous snapshot
const data = this.etrade.extractRegistrationData(businessInfo, companyInfo);
const codes = cfg.invoice;
// e-Trade returns region/zone/woreda as names ("Addis Ababa", "Bole") — resolved through the
// same MoR location master the buyer side uses, since the geography is objective, not
// buyer-specific. `tryResolveMorGeo` never throws: an address MoR does not list simply
// leaves these fields to getSellerDetails' static-config fallback, which is authoritative
// anyway (see the class comment — MoR has already cleared the static seller values under
// rule 7017, so nothing here may override one). e-Trade carries no country field; the
// resolver reads a blank country as domestic, which is correct for EDR's own registration.
const geo = tryResolveMorGeo({
region: data.region,
zone: data.zone,
woreda: data.woreda,
});
this.cached = {
LegalName: data.companyName || undefined,
Phone: data.mobilePhone || data.regularPhone || undefined,
// e-Trade returns region/zone/woreda as names ("Addis Ababa", "Bole") — resolved via the
// same buyer code maps, since the geography is objective, not buyer-specific, despite the
// env var's "BUYER_" prefix. Never throws: an unmapped name just leaves that field to
// getSellerDetails' static-config fallback.
Region: resolveOptionalCode(data.region, codes.buyerRegionCodes),
Wereda: resolveOptionalCode(data.woreda, codes.buyerWeredaCodes),
City: resolveOptionalCode(data.zone, codes.buyerCityCodes),
Region: geo?.Region,
Wereda: geo?.Wereda,
City: geo?.City,
};
} catch (err) {
this.logger.warn(

View File

@@ -32,11 +32,6 @@ export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsIn
paymentMode: "CASH",
paymentTerm: "IMMIDIATE",
unitDefault: "PCS",
buyerCountryCode: null,
buyerCountryCodes: { Ethiopia: "231" }, // test-only, not a confirmed real MoR code
buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code
buyerCityCodes: { Kirkos: "101" }, // test-only, not a confirmed real MoR code
taxCodeByChargeType: {},
taxRateByChargeType: {},
exciseByChargeType: {},

View File

@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { WarehousesModule } from '../warehouses/warehouses.module';
import { DjiboutiIncident } from './entities/djibouti-incident.entity';
import { EmptyContainerReturn } from './entities/empty-container-return.entity';
@@ -18,9 +20,12 @@ import { ImportOperationsService } from './import-operations.service';
]),
// WarehouseReleaseDocumentService (the shared PDF renderer) for the
// equipment interchange receipt; BookingsModule for the customer
// ownership check on that same route.
// ownership check on that same route; the notification modules to tell
// the customer their receipt is ready at handover.
WarehousesModule,
BookingsModule,
NotificationInboxModule,
NotificationsModule,
],
controllers: [ImportOperationsController],
providers: [ImportOperationsService],

View File

@@ -1,9 +1,13 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { NotificationAudience, NotificationType } from '@edr/types';
import { LogoSettingsService } from '../logo-settings/logo-settings.service';
import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { NotificationsService } from '../notifications/notifications.service';
import { sendCompanyChannels } from '../notifications/notify-company.util';
import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service';
import {
CreateDjiboutiIncidentDto,
@@ -35,6 +39,8 @@ const DAMAGE_INCIDENTS: DjiboutiIncidentType[] = [
@Injectable()
export class ImportOperationsService {
private readonly logger = new Logger(ImportOperationsService.name);
constructor(
@InjectRepository(DjiboutiIncident)
private readonly incidents: Repository<DjiboutiIncident>,
@@ -44,6 +50,8 @@ export class ImportOperationsService {
private readonly emptyReturns: Repository<EmptyContainerReturn>,
private readonly pdfDocuments: WarehouseReleaseDocumentService,
private readonly logoSettings: LogoSettingsService,
private readonly inbox: NotificationInboxService,
private readonly notifications: NotificationsService,
) {}
listIncidents(bookingId?: string) {
@@ -161,7 +169,7 @@ export class ImportOperationsService {
async createEmptyReturn(dto: CreateEmptyContainerReturnDto) {
const returnDate = dto.returnDate ? new Date(dto.returnDate) : new Date();
return this.emptyReturns.save(
const saved = await this.emptyReturns.save(
this.emptyReturns.create({
containerNumber: dto.containerNumber,
bookingId: dto.bookingId ?? null,
@@ -180,6 +188,15 @@ export class ImportOperationsService {
],
}),
);
// RETURNED is the physical interchange itself — the customer's/trucker's
// custody of the box ends here, EDR's begins. The receipt exists from this
// point on, so tell the customer now, not at some later internal status.
// Standalone returns (no booking) have no company to notify.
if (saved.bookingId) {
await this.notifyEquipmentInterchangeReady(saved);
}
return saved;
}
/**
@@ -257,6 +274,34 @@ export class ImportOperationsService {
return this.emptyReturns.findOneOrFail({ where: { id } });
}
private async notifyEquipmentInterchangeReady(row: EmptyContainerReturn): Promise<void> {
try {
const [booking]: Array<{ companyId: string | null; reference: string }> =
await this.emptyReturns.manager.query(
`SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[row.bookingId],
);
if (!booking?.companyId) return;
const body = `Container ${row.containerNumber} was handed over${
row.facility ? ` at ${row.facility}` : ''
}. Your equipment interchange receipt for booking ${booking.reference} is ready to download from the portal.`;
await this.inbox.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.DOCUMENT_ACTION,
title: 'Equipment interchange receipt ready',
body,
link: `/bookings/${row.bookingId}`,
data: { bookingId: row.bookingId, emptyContainerReturnId: row.id },
});
await sendCompanyChannels(this.emptyReturns.manager.connection, this.notifications, booking.companyId, body);
} catch (err) {
this.logger.warn(
`Failed to notify equipment interchange ready for return ${row.id}: ${(err as Error).message}`,
);
}
}
async getEmptyReturnOrThrow(id: string): Promise<EmptyContainerReturn> {
const row = await this.emptyReturns.findOne({ where: { id } });
if (!row) {

View File

@@ -0,0 +1,278 @@
/**
* Converts a Ministry of Revenues EIMS location workbook into `src/config/mor-locations.data.ts`.
*
* pnpm --filter @edr/freight-api eims:import-locations <workbook.xlsx> [--sheet SHEET_NAME]
*
* Exists so a future MoR workbook replaces the dataset by rerunning one command and reviewing the
* diff, instead of anyone hand-editing a thousand rows of tax reference data. Production never
* parses the workbook: the committed TypeScript is what ships.
*
* The generated file reproduces the Ministry's own labels and IDs verbatim. This script validates
* and reports; it does not correct. Spelling compatibility with EDR/e-Trade names lives in
* `mor-location.resolver.ts`, so the dataset stays traceable back to the source sheet.
*/
import { writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { Workbook } from "exceljs";
const DEFAULT_SHEET = "EIMS_COUNTRY_REGION_VW";
const OUTPUT = resolve(__dirname, "../config/mor-locations.data.ts");
const COLUMNS = [
"COUNTRY_NO",
"COUNTRY_NAME",
"PARISH_NO",
"PARISH_NAME",
"CITY_NO",
"CITY_NAME",
"LOCALITY_NO",
"LOCALITY_DESC",
] as const;
type Column = (typeof COLUMNS)[number];
type Row = [number, string, number, string, number, string, number, string];
/** Header cells arrive with stray casing, spaces and non-breaking spaces; compare on this form. */
const headerKey = (value: unknown): string =>
String(value ?? "")
.toUpperCase()
.replace(/[^A-Z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
/**
* Cell text with only the transport-layer damage removed (Excel's non-breaking spaces, and the
* CR/LF a wrapped cell leaves behind). Deliberately keeps the Ministry's own leading/trailing
* spaces and spelling — the resolver normalizes at comparison time, the dataset stays as supplied.
*/
const cellText = (value: unknown): string => {
if (value === null || value === undefined) return "";
const raw =
typeof value === "object" && "text" in (value as object)
? String((value as { text: unknown }).text ?? "")
: String(value);
return raw.replace(/\u00a0/g, " ").replace(/\r?\n/g, " ");
};
const cellNumber = (value: unknown): number | null => {
const text = cellText(value).trim();
if (!/^[0-9]+$/.test(text)) return null;
return Number(text);
};
async function main(): Promise<void> {
const args = process.argv.slice(2);
const sheetFlag = args.indexOf("--sheet");
const sheetName = sheetFlag >= 0 ? args[sheetFlag + 1] : DEFAULT_SHEET;
const workbookPath = args.find((arg, i) => !arg.startsWith("--") && i !== sheetFlag + 1);
if (!workbookPath) {
throw new Error(
"Usage: eims:import-locations <workbook.xlsx> [--sheet SHEET_NAME]\n" +
`Defaults to sheet "${DEFAULT_SHEET}".`,
);
}
const workbook = new Workbook();
await workbook.xlsx.readFile(resolve(process.cwd(), workbookPath));
const sheet = workbook.getWorksheet(sheetName);
if (!sheet) {
const available = workbook.worksheets.map((w) => w.name).join(", ");
throw new Error(`Sheet "${sheetName}" not found. Sheets in this workbook: ${available}`);
}
// The header is not guaranteed to be row 1 — find the first row carrying every required column.
let headerRow = 0;
let columnIndex: Partial<Record<Column, number>> = {};
for (let r = 1; r <= Math.min(sheet.rowCount, 20); r++) {
const found: Partial<Record<Column, number>> = {};
sheet.getRow(r).eachCell({ includeEmpty: false }, (cell, colNumber) => {
const key = headerKey(cell.value) as Column;
if (COLUMNS.includes(key) && found[key] === undefined) found[key] = colNumber;
});
if (COLUMNS.every((c) => found[c] !== undefined)) {
headerRow = r;
columnIndex = found;
break;
}
}
if (headerRow === 0) {
throw new Error(
`Sheet "${sheetName}" has no header row containing all required columns: ${COLUMNS.join(", ")}`,
);
}
const rows: Row[] = [];
const problems: string[] = [];
for (let r = headerRow + 1; r <= sheet.rowCount; r++) {
const sheetRow = sheet.getRow(r);
const at = (column: Column): unknown => sheetRow.getCell(columnIndex[column]!).value;
// A sheet exported from a view is usually padded with blank rows at the end; skip silently.
if (COLUMNS.every((c) => cellText(at(c)).trim() === "")) continue;
const countryNo = cellNumber(at("COUNTRY_NO"));
const parishNo = cellNumber(at("PARISH_NO"));
const cityNo = cellNumber(at("CITY_NO"));
const localityNo = cellNumber(at("LOCALITY_NO"));
const countryName = cellText(at("COUNTRY_NAME"));
const parishName = cellText(at("PARISH_NAME"));
const cityName = cellText(at("CITY_NAME"));
const localityDesc = cellText(at("LOCALITY_DESC"));
const missingIds = (
[
["COUNTRY_NO", countryNo],
["PARISH_NO", parishNo],
["CITY_NO", cityNo],
["LOCALITY_NO", localityNo],
] as const
)
.filter(([, value]) => value === null)
.map(([name]) => name);
const blankNames = (
[
["COUNTRY_NAME", countryName],
["PARISH_NAME", parishName],
["CITY_NAME", cityName],
["LOCALITY_DESC", localityDesc],
] as const
)
.filter(([, value]) => value.trim() === "")
.map(([name]) => name);
if (missingIds.length > 0 || blankNames.length > 0) {
problems.push(
`row ${r}: ${[
missingIds.length ? `non-numeric/missing ${missingIds.join(", ")}` : "",
blankNames.length ? `blank ${blankNames.join(", ")}` : "",
]
.filter(Boolean)
.join("; ")}`,
);
continue;
}
rows.push([
countryNo!,
countryName,
parishNo!,
parishName,
cityNo!,
cityName,
localityNo!,
localityDesc,
]);
}
if (problems.length > 0) {
throw new Error(
`Sheet "${sheetName}" has ${problems.length} unusable row(s); nothing was written:\n ` +
problems.slice(0, 25).join("\n ") +
(problems.length > 25 ? `\n ... and ${problems.length - 25} more` : ""),
);
}
if (rows.length === 0) {
throw new Error(`Sheet "${sheetName}" has a valid header but no data rows.`);
}
// Exact duplicates carry no information and only inflate the file — collapsed here, and the
// count reported, so the collapse is a stated decision rather than a silent one. IDs are never
// touched: only whole identical rows are dropped.
const seen = new Map<string, Row>();
for (const row of rows) {
const key = JSON.stringify(row);
if (!seen.has(key)) seen.set(key, row);
}
const unique = [...seen.values()];
const duplicates = rows.length - unique.length;
// Deterministic output: same workbook in, byte-identical file out, so a regeneration diff shows
// only what the Ministry actually changed.
unique.sort(
(a, b) =>
a[0] - b[0] ||
a[2] - b[2] ||
a[4] - b[4] ||
a[6] - b[6] ||
a[7].localeCompare(b[7]) ||
a[5].localeCompare(b[5]),
);
// A name that resolves to two different codes under the same parent cannot be resolved by any
// amount of normalization — the resolver refuses it as ambiguous at filing time rather than
// picking one. Reported here so it can be raised with MoR instead of surfacing on a live invoice.
const byPath = new Map<string, Set<number>>();
const collide = (level: string, path: string, name: string, no: number): void => {
const key = [level, path, name.trim().toUpperCase().replace(/\s+/g, " ")].join(" | ");
if (!byPath.has(key)) byPath.set(key, new Set());
byPath.get(key)!.add(no);
};
for (const [cNo, cName, pNo, pName, tNo, tName, lNo, lName] of unique) {
collide("COUNTRY_NAME", "", cName, cNo);
collide("PARISH_NAME", String(cNo), pName, pNo);
collide("CITY_NAME", `${cNo}/${pNo}`, tName, tNo);
collide("LOCALITY_DESC", `${cNo}/${pNo}/${tNo}`, lName, lNo);
}
const conflicts = [...byPath.entries()]
.filter(([, codes]) => codes.size > 1)
.map(([key, codes]) => {
const [level, path, name] = key.split(" | ");
const where = path ? ` under ${path}` : "";
return ` ${level} "${name}"${where} -> codes ${[...codes].sort((a, b) => a - b).join(", ")}`;
})
.sort();
const header = `/**
* GENERATED FILE — do not hand-edit.
*
* MoR EIMS location master (\`${sheetName}\`), the Ministry's own geographic reference data.
* Regenerate from a supplied workbook with:
*
* pnpm --filter @edr/freight-api eims:import-locations <path-to.xlsx>
*
* Values are reproduced verbatim from the Ministry sheet — original spelling, original casing,
* original numbering. Nothing here is cleaned up or renumbered: this file is the traceable copy of
* the source. Spelling compatibility between EDR/e-Trade names and MoR names belongs in
* \`mor-location.resolver.ts\`'s normalization and alias layer, never here.
*/
/** \`[COUNTRY_NO, COUNTRY_NAME, PARISH_NO, PARISH_NAME, CITY_NO, CITY_NAME, LOCALITY_NO, LOCALITY_DESC]\` */
export type MorLocationTuple = [number, string, number, string, number, string, number, string];
export const MOR_LOCATIONS: MorLocationTuple[] = [
`;
const body = unique
.map(
([cNo, cName, pNo, pName, tNo, tName, lNo, lName]) =>
` [${cNo}, ${JSON.stringify(cName)}, ${pNo}, ${JSON.stringify(pName)}, ${tNo}, ` +
`${JSON.stringify(tName)}, ${lNo}, ${JSON.stringify(lName)}],`,
)
.join("\n");
writeFileSync(OUTPUT, `${header}${body}\n];\n`, "utf8");
const countries = new Set(unique.map((r) => r[0])).size;
const regions = new Set(unique.map((r) => `${r[0]}/${r[2]}`)).size;
const zones = new Set(unique.map((r) => `${r[0]}/${r[2]}/${r[4]}`)).size;
console.log(`Wrote ${OUTPUT}`);
console.log(
` ${unique.length} rows - ${countries} countries, ${regions} regions, ${zones} zones` +
(duplicates > 0 ? `; collapsed ${duplicates} exact duplicate row(s)` : ""),
);
if (conflicts.length > 0) {
console.log(
` ${conflicts.length} same-hierarchy name conflict(s) - these resolve to an ambiguity ` +
"error at filing time, never to a guess:",
);
console.log(conflicts.join("\n"));
}
}
main().catch((err: Error) => {
console.error(err.message);
process.exit(1);
});

View File

@@ -780,6 +780,8 @@ export const URL_CONSTANTS = {
`/import-operations/empty-container-returns/${id}/status`,
EMPTY_CONTAINER_RETURNS_LOAD_ON_TRAIN:
"/import-operations/empty-container-returns/load-on-train",
EMPTY_CONTAINER_RETURN_DOCUMENT: (id: string) =>
`/import-operations/empty-container-returns/${id}/document`,
},
VEHICLES: {

View File

@@ -19,12 +19,14 @@ import {
Select,
Checkbox,
} from "@mantine/core";
import { ChevronDown, ChevronRight, History } from "lucide-react";
import { ChevronDown, ChevronRight, FileText, History } from "lucide-react";
import { DataTable, type ColumnDef } from "@edr/ui-common";
import { PageContainer, PageHeader } from "@/components/page";
import ListControls from "@/components/common/ListControls";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
import { openPdfBlob } from "@/components/warehouses/pdf";
import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart";
import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart";
import { useListControls, toDayString } from "@/hooks/useListControls";
@@ -103,6 +105,25 @@ export default function ContainerReturnsPage() {
const [activeKey, setActiveKey] = useState<string | null>(null);
const [historyRow, setHistoryRow] = useState<any | null>(null);
const [allocateRow, setAllocateRow] = useState<EmptyContainerReturn | null>(null);
const [documentBusyId, setDocumentBusyId] = useState<string | null>(null);
const viewInterchangeDocument = async (ret: EmptyContainerReturn) => {
setDocumentBusyId(ret.id);
const pdfWindow = window.open("", "_blank");
try {
const response = await importOperationsService.downloadEquipmentInterchangeDocument(ret.id);
openPdfBlob(response.data, `equipment-interchange-${ret.containerNumber}.pdf`, pdfWindow);
} catch (error) {
pdfWindow?.close();
toast({
variant: "destructive",
title: "Could not open interchange receipt",
description: await extractDownloadErrorMessage(error),
});
} finally {
setDocumentBusyId(null);
}
};
const { data: unloadedQueue = [], isLoading: queueLoading } = useQuery({
queryKey: ["import-unloaded-queue"],
@@ -416,6 +437,15 @@ export default function ContainerReturnsPage() {
const nextStatus = RETURN_STATUS_ORDER[RETURN_STATUS_ORDER.indexOf(ret.status) + 1];
return (
<Group gap="xs" justify="flex-end" wrap="nowrap">
<ActionIcon
variant="subtle"
color="gray"
onClick={() => void viewInterchangeDocument(ret)}
loading={documentBusyId === ret.id}
title="View equipment interchange receipt"
>
<FileText size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"

View File

@@ -144,4 +144,10 @@ export const importOperationsService = {
);
return unwrap(response.data);
},
/** Equipment interchange receipt — the doc handed to the customer at handover. */
downloadEquipmentInterchangeDocument: (id: string) =>
client.get<Blob>(URL_CONSTANTS.IMPORT_OPERATIONS.EMPTY_CONTAINER_RETURN_DOCUMENT(id), {
responseType: 'blob',
}),
};