mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 07:23:40 +00:00
fix(eims): resolve Addis Ababa addresses against MoR's sub-city tier
MoR's location master has no zone level in a chartered city: CITY_NAME is the sub-city and LOCALITY_DESC is the numbered woreda. e-Trade fills the gap by repeating the region in ZONE, pushing the sub-city into CITY_NAME and the woreda number into LOCALITY_DESC, so every Addis Ababa buyer failed CITY_NAME lookup and could not be filed at all. Retry one level down when ZONE repeats the region, accepting it only when both shifted levels resolve exactly — an unknown zone still fails with its own message. Match a zero-padded 07 against MoR's own WOREDA 7 spelling, and add the reviewed AMAHARA / KOLFIE KERANIYO / MISRAK SHOA aliases. Verified against the dev database: 2 of 40 companies resolved before, 31 after.
This commit is contained in:
@@ -32,6 +32,7 @@
|
||||
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
|
||||
"seed:dropdown-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-dropdown-settings.ts",
|
||||
"seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts",
|
||||
"seed:mor-test-buyers": "ts-node -r tsconfig-paths/register src/scripts/seed-mor-test-buyers.ts",
|
||||
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh",
|
||||
"iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js",
|
||||
"iam:migration:run": "pnpm run iam:typeorm:cli migration:run",
|
||||
|
||||
@@ -29,6 +29,8 @@ const FIXTURE: MorLocationTuple[] = [
|
||||
[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"],
|
||||
[70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1102, "WOREDA 3"],
|
||||
[70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1139, "WOREDA 7"],
|
||||
[253, "Djibouti", 1, "DJIBOUTI", 1, "DJIBOUTI VILLE", 1, "BALBALA"],
|
||||
];
|
||||
|
||||
@@ -181,6 +183,93 @@ describe("resolveMorGeo", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Addis Ababa, where MoR has no zone tier", () => {
|
||||
// e-Trade's real shape for a chartered city: `zone` repeats the region, the sub-city sits in
|
||||
// `woreda`, and the numbered woreda sits in `kebele`. This is how every company imported from
|
||||
// e-Trade stores an Addis Ababa address, and it is the shape that blocked INV-20260829-00011.
|
||||
const ETRADE_SHAPE = {
|
||||
country: "Ethiopia",
|
||||
region: "Addis Ababa",
|
||||
zone: "Addis Ababa",
|
||||
woreda: "Kolfe-Keraniyo",
|
||||
kebele: "07",
|
||||
};
|
||||
|
||||
it("reads the sub-city and woreda one level down when the zone repeats the region", () => {
|
||||
expect(resolveMorGeo(ETRADE_SHAPE, FIXTURE)).toEqual({
|
||||
Country: "70",
|
||||
Region: "13",
|
||||
City: "81",
|
||||
Wereda: "1139",
|
||||
});
|
||||
});
|
||||
|
||||
it("matches MoR's own 'KOLFIE KERANIYO' spelling of the sub-city", () => {
|
||||
expect(resolveMorGeo({ ...ETRADE_SHAPE, woreda: "Kolfe Keranio" }, FIXTURE).City).toBe("81");
|
||||
});
|
||||
|
||||
it("reads a zero-padded number as MoR's 'WOREDA n' locality, in either slot", () => {
|
||||
const bole = { country: "Ethiopia", region: "Addis Ababa", zone: "Bole" };
|
||||
expect(resolveMorGeo({ ...bole, woreda: "03" }, FIXTURE).Wereda).toBe("1102");
|
||||
expect(resolveMorGeo({ ...bole, woreda: "Woreda 03" }, FIXTURE).Wereda).toBe("1102");
|
||||
expect(resolveMorGeo({ ...bole, woreda: "WOREDA 3" }, FIXTURE).Wereda).toBe("1102");
|
||||
});
|
||||
|
||||
it("still resolves the already-correct shape without shifting", () => {
|
||||
expect(
|
||||
resolveMorGeo(
|
||||
{ country: "Ethiopia", region: "ADDIS ABABA", zone: "BOLE", woreda: "WOREDA 1" },
|
||||
FIXTURE,
|
||||
),
|
||||
).toEqual({ Country: "70", Region: "13", City: "78", Wereda: "1100" });
|
||||
});
|
||||
|
||||
it("reports the zone failure, not the shifted one, when the shift does not resolve", () => {
|
||||
// LEMI KURA is a 2020 sub-city the Ministry sheet does not list. The shift must not turn
|
||||
// that into a confusing locality error, and must never land on a neighbouring sub-city.
|
||||
expect(() =>
|
||||
resolveMorGeo({ ...ETRADE_SHAPE, woreda: "Lemi Kura", kebele: "02" }, FIXTURE),
|
||||
).toThrow(/no MoR CITY_NAME match for country="Ethiopia", region="Addis Ababa"/);
|
||||
});
|
||||
|
||||
it("does not shift when the zone is simply an unknown zone", () => {
|
||||
expect(() =>
|
||||
resolveMorGeo(
|
||||
{
|
||||
country: "Ethiopia",
|
||||
region: "OROMIA",
|
||||
zone: "East Zone",
|
||||
woreda: "KERSA",
|
||||
kebele: "01",
|
||||
},
|
||||
FIXTURE,
|
||||
),
|
||||
).toThrow(/no MoR CITY_NAME match/);
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves the regions and zones MoR spells differently from e-Trade", () => {
|
||||
// Guards the reviewed alias table: MoR's PARISH_NAME is "AMAHARA", and it keeps the Amharic
|
||||
// compass words for the Oromia zones ("MISRAK SHOA" for East Shewa).
|
||||
const rows: MorLocationTuple[] = [
|
||||
...FIXTURE,
|
||||
[70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 21, "ADAMA"],
|
||||
[70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 149, "MECHA"],
|
||||
];
|
||||
expect(
|
||||
resolveMorGeo(
|
||||
{ country: "Ethiopia", region: "Oromia", zone: "East Shewa", woreda: "Adama" },
|
||||
rows,
|
||||
),
|
||||
).toEqual({ Country: "70", Region: "2", City: "16", Wereda: "21" });
|
||||
expect(
|
||||
resolveMorGeo(
|
||||
{ country: "Ethiopia", region: "Amhara", zone: "West Gojjam", woreda: "Mecha" },
|
||||
rows,
|
||||
),
|
||||
).toEqual({ Country: "70", Region: "11", City: "53", Wereda: "149" });
|
||||
});
|
||||
|
||||
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/],
|
||||
|
||||
@@ -43,6 +43,11 @@ export interface MorAddressInput {
|
||||
region?: string | null;
|
||||
zone?: string | null;
|
||||
woreda?: string | null;
|
||||
/**
|
||||
* Only read for the city-region shift below — in Addis Ababa e-Trade stores the numbered woreda
|
||||
* here. Never consulted for an ordinary region/zone/woreda address.
|
||||
*/
|
||||
kebele?: string | null;
|
||||
}
|
||||
|
||||
type Level = "country" | "region" | "zone" | "woreda";
|
||||
@@ -115,6 +120,24 @@ const ALIASES: MorAlias[] = [
|
||||
from: "Jigjiga",
|
||||
to: "JIJIGA",
|
||||
},
|
||||
// MoR misspells the region itself — PARISH_NO 11 is "AMAHARA". No other parish is close to it.
|
||||
{ level: "region", from: "Amhara", to: "AMAHARA" },
|
||||
// Addis Ababa sub-cities, where MoR's sheet and e-Trade disagree on spelling. Each confirmed by
|
||||
// CITY_NO under PARISH_NO 13; the seven that already agree (ARADA, ADDIS KETEMA, LIDETA, KIRKOS,
|
||||
// YEKA, BOLE, GULLELE) need no entry. LEMI KURA is deliberately absent — the Ministry sheet does
|
||||
// not list the 2020 split at all, so it must keep failing rather than be mapped onto a neighbour.
|
||||
{ level: "zone", region: "ADDIS ABABA", from: "Kolfe Keraniyo", to: "KOLFIE KERANIYO" }, // 81
|
||||
{ level: "zone", region: "ADDIS ABABA", from: "Kolfe Keranio", to: "KOLFIE KERANIYO" }, // 81
|
||||
{ level: "zone", region: "ADDIS ABABA", from: "Nifas Silk Lafto", to: "NEFAS SILK LAFTO" }, // 80
|
||||
{ level: "zone", region: "ADDIS ABABA", from: "Akaki Kality", to: "AKAKI KALITI" }, // 79
|
||||
// MoR keeps the Amharic compass words for the Oromia zones; e-Trade stores the English ones.
|
||||
// Each pair confirmed by the zone's own localities in the sheet: MISRAK SHOA holds ADAMA and
|
||||
// BISHOFTU, MIRAB SHOA holds AMBO and WELMERA, MIRAB HARARGE holds CHIRO and GEMMECHIS.
|
||||
{ level: "zone", region: "OROMIA", from: "East Shewa", to: "MISRAK SHOA" }, // 16
|
||||
{ level: "zone", region: "OROMIA", from: "West Shewa", to: "MIRAB SHOA" }, // 62
|
||||
{ level: "zone", region: "OROMIA", from: "West Hararge", to: "MIRAB HARARGE" }, // 7
|
||||
// MoR drops a J. Confirmed by BAHIRDAR ZURIA / MECHA / BURIE sitting under CITY_NO 53.
|
||||
{ level: "zone", region: "AMAHARA", from: "West Gojjam", to: "WEST GOJAM" }, // 53
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -127,6 +150,20 @@ const ALIASES: MorAlias[] = [
|
||||
const zoneSuffixCandidates = (normalized: string): string[] =>
|
||||
normalized.endsWith(" ZONE") ? [] : [`${normalized} ZONE`];
|
||||
|
||||
/**
|
||||
* In the chartered cities MoR names each locality "WOREDA 7", while e-Trade stores the bare,
|
||||
* zero-padded number ("07") and EDR's own forms sometimes store "Woreda 05". All three mean the
|
||||
* same locality, so the MoR spelling is tried as a second exact-match candidate — MoR writes no
|
||||
* leading zero, hence the strip. Applied to the locality level only.
|
||||
*
|
||||
* This runs ahead of the numeric LOCALITY_NO fallback below, and can never mask it: no city in the
|
||||
* Ministry sheet contains both a "WOREDA n" locality and a locality whose LOCALITY_NO is n.
|
||||
*/
|
||||
const woredaNumberCandidates = (normalized: string): string[] => {
|
||||
const match = /^(?:WOREDA )?0*([0-9]{1,2})$/.exec(normalized);
|
||||
return match ? [`WOREDA ${match[1]}`] : [];
|
||||
};
|
||||
|
||||
export class MorGeoMappingError extends BadRequestException {
|
||||
constructor(code: "EIMS_GEO_MAPPING_FAILED" | "EIMS_GEO_AMBIGUOUS", message: string) {
|
||||
super({ code, message });
|
||||
@@ -158,6 +195,7 @@ function matchLevel(
|
||||
if (normalizeName(alias.from) === wanted) candidates.push(normalizeName(alias.to));
|
||||
}
|
||||
if (level === "zone") candidates.push(...zoneSuffixCandidates(wanted));
|
||||
if (level === "woreda") candidates.push(...woredaNumberCandidates(wanted));
|
||||
}
|
||||
|
||||
let matched: MorLocationTuple[] = [];
|
||||
@@ -221,25 +259,46 @@ export function resolveMorGeo(
|
||||
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),
|
||||
type Name = string | null | undefined;
|
||||
const below = (zone: Name, woreda: Name): MorGeoCodes => {
|
||||
const inZone = matchLevel(inRegion.rows, "zone", zone, { region: regionScope }, input);
|
||||
const zoneScope = normalizeName(inZone.rows[0][SLOTS.zone.name] as string);
|
||||
const inWoreda = matchLevel(
|
||||
inZone.rows,
|
||||
"woreda",
|
||||
woreda,
|
||||
{
|
||||
region: regionScope,
|
||||
zone: zoneScope,
|
||||
},
|
||||
input,
|
||||
);
|
||||
return {
|
||||
Country: String(inCountry.no),
|
||||
Region: String(inRegion.no),
|
||||
City: String(inZone.no),
|
||||
Wereda: String(inWoreda.no),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
return below(input.zone, input.woreda);
|
||||
} catch (err) {
|
||||
// Addis Ababa (and every other chartered city) has no zone tier: MoR's CITY level *is* the
|
||||
// sub-city and its LOCALITY level is the numbered woreda. e-Trade fills the missing tier by
|
||||
// repeating the region in `zone`, which pushes the sub-city into `woreda` and the woreda
|
||||
// number into `kebele` — one level down the whole way. Retry with that reading, but only when
|
||||
// `zone` genuinely repeats the region, and only accept it when *both* shifted levels resolve
|
||||
// exactly. A zone MoR simply does not list still fails with its own message, unreinterpreted.
|
||||
const zone = normalizeName(input.zone);
|
||||
if (!zone || (zone !== regionScope && zone !== normalizeName(input.region))) throw err;
|
||||
try {
|
||||
return below(input.woreda, input.kebele);
|
||||
} catch {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Non-throwing variant for callers that already have a working fallback (the seller identity). */
|
||||
|
||||
@@ -134,6 +134,7 @@ export class EimsBulkRegistrationService {
|
||||
region: invoice.company?.region,
|
||||
zone: invoice.company?.zone,
|
||||
woreda: invoice.company?.woreda,
|
||||
kebele: invoice.company?.kebele,
|
||||
});
|
||||
return { invoice, documentType, relatedDocument, buyerGeo };
|
||||
});
|
||||
|
||||
@@ -126,6 +126,7 @@ export class EimsInvoiceRegistrationService {
|
||||
region: invoice.company?.region,
|
||||
zone: invoice.company?.zone,
|
||||
woreda: invoice.company?.woreda,
|
||||
kebele: invoice.company?.kebele,
|
||||
});
|
||||
|
||||
// Authenticate before reserving: the source system comes from the token, and the state row is
|
||||
|
||||
@@ -116,6 +116,7 @@ export class EimsSellerCacheService implements OnModuleInit {
|
||||
region: data.region,
|
||||
zone: data.zone,
|
||||
woreda: data.woreda,
|
||||
kebele: data.kebele,
|
||||
});
|
||||
this.cached = {
|
||||
// The *legal* entity name, not the licence's trade name that
|
||||
|
||||
100
apps/edr-freight-api/src/scripts/seed-mor-test-buyers.ts
Normal file
100
apps/edr-freight-api/src/scripts/seed-mor-test-buyers.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { AppDataSource } from "../data-source";
|
||||
|
||||
/**
|
||||
* Seeds the 30 test taxpayers the Ministry of Revenues issued for the EIMS/BSP
|
||||
* **non-self buyer** certification run — the checklist item that needs a real
|
||||
* invoice filed against a buyer that is not EDR itself.
|
||||
*
|
||||
* Every field comes from MoR's own roster, except the two location codes, which
|
||||
* do not survive contact with the Ministry's own location master:
|
||||
*
|
||||
* - MoR's sheet gives `Region = "1"`. `PARISH_NO 1` is not an Ethiopian region
|
||||
* at all (it is Djibouti), so the region is stored by name — `Bole` is an
|
||||
* Addis Ababa sub-city, which fixes the region unambiguously (PARISH_NO 13).
|
||||
* - MoR's sheet gives `City = "101"`, which is GOFA ZONE in SNNPRS. The buyer
|
||||
* is in Bole, so the sub-city is stored by name (CITY_NO 78).
|
||||
*
|
||||
* In other words MoR does not validate the geographic codes it sends itself;
|
||||
* these rows carry the addresses that actually resolve. The roster carries no
|
||||
* woreda, so every row takes `NO WOREDA-144` — MoR's *own* "not specified"
|
||||
* locality under BOLE (LOCALITY_NO 574), the same code EDR's static seller
|
||||
* details already file under. Nothing here is invented.
|
||||
*
|
||||
* Idempotent: `ON CONFLICT (tin) DO NOTHING`. TIN 0089238373 is already on file
|
||||
* as a real customer (Afri Software Solutions) and is deliberately left alone.
|
||||
*/
|
||||
|
||||
/**
|
||||
* `[TIN, phone, legal name, email, kebele?, house number?]`, verbatim from MoR's roster. The two
|
||||
* trailing fields default to the values 22 of the 30 rows share.
|
||||
*/
|
||||
const KEBELE = "Near Bole Airport";
|
||||
const HOUSE_NO = "123B";
|
||||
|
||||
const MOR_TEST_BUYERS: Array<[string, string, string, string, string?, string?]> = [
|
||||
["0089238373", "251911091245", "Taxpayer A", "codethicaet@gmail.com", "Near Airport", "101"],
|
||||
["0054864576", "251911091245", "Taxpayer B", "shehir8@gmail.com", "Near Airport"],
|
||||
["0049056594", "251911091245", "Taxpayer C", "teme@odooethiopia.com", "Near Airport"],
|
||||
["0059819904", "251911091245", "Taxpayer D", "rubiethoplc@gmail.com", "Near Airport"],
|
||||
["0000018932", "251911091245", "Taxpayer E", "amanuelephremedu@gmail.com", "Near Airport"],
|
||||
["0088683375", "251911091245", "Taxpayer F", "ermiastegegn576@gmail.com", "Near Airport"],
|
||||
["0068421445", "251911091245", "Taxpayer G", "qelemmeda@gmail.com", "Near Airport"],
|
||||
["0004404844", "251911091245", "Taxpayer H", "dagnegamu24@gmail.com"],
|
||||
["0000037187", "251911091245", "Taxpayer I", "deresr.belay@gmail.com"],
|
||||
["0050167460", "251911091245", "Taxpayer J", "sera2013ec@gmail.com"],
|
||||
["0079690836", "251909978781", "Taxpayer K", "asmeradefa@gmail.com"],
|
||||
["0068180813", "251944310004", "Taxpayer L", "hailelt@gmail.com"],
|
||||
["0083907363", "251944310004", "Taxpayer M", "dawitfissha1@gmail.com"],
|
||||
["0089032785", "251944310004", "Taxpayer N", "tewahido11@gmail.com"],
|
||||
["0003826418", "251944310004", "Taxpayer O", "alemayehu.t@marakisoft.com"],
|
||||
["0053374665", "251944310004", "Taxpayer P", "getlelaw@gmail.com"],
|
||||
["0016175194", "251911463482", "Taxpayer Q", "abiye.abi@gmail.com", "Near Airport"],
|
||||
["0094542975", "251911463482", "Taxpayer R", "abelgebreananya@gmail.com"],
|
||||
// MoR's roster carries an 11-digit phone here; kept verbatim rather than "corrected".
|
||||
["0088514835", "25191124368", "Taxpayer S", "ewnget77@gmail.com"],
|
||||
["0076217301", "251960403750", "Taxpayer T", "merontamirat.redcloud@gmail.com"],
|
||||
["0003826419", "251911516507", "Taxpayer 322", "alemayehu.t@marakisoft.com"],
|
||||
["0056961577", "251929020729", "Taxpayer 323", "ltictsolution@gmail.com", undefined, "1234B"],
|
||||
["0090853345", "251911376145", "Taxpayer 324", "kidusgoshu2be@gmail.com"],
|
||||
["0000028643", "251988899003", "Taxpayer 325", "mesaysisay10@gmail.com"],
|
||||
["0057751727", "251911437928", "Taxpayer 326", "zewdugeta@gmail.com"],
|
||||
["0082549522", "251907256543", "Taxpayer 327", "brookgm2@gmail.com"],
|
||||
["0093283311", "251953915419", "Taxpayer 328", "henock.ad@gmail.com"],
|
||||
["0078795374", "251911091245", "Taxpayer 329", "danielltadesse@gmail.com"],
|
||||
["0093346931", "251935724920", "Taxpayer 330", "halidabd63@gmail.com"],
|
||||
["0040887091", "251913792959", "Taxpayer 331", "milextech@gmail.com"],
|
||||
];
|
||||
|
||||
async function seedMorTestBuyers(): Promise<void> {
|
||||
await AppDataSource.initialize();
|
||||
try {
|
||||
for (const [tin, phone, name, email, kebele, houseNo] of MOR_TEST_BUYERS) {
|
||||
await AppDataSource.query(
|
||||
`INSERT INTO freight.companies
|
||||
(name, type, kind, status, tin, country, region, zone, woreda, kebele,
|
||||
house_no, phone, email)
|
||||
VALUES ($1, 'customer', 'commercial', 'active', $2, 'Ethiopia', 'Addis Ababa', 'Bole',
|
||||
'NO WOREDA-144', $3, $4, $5, $6)
|
||||
ON CONFLICT (tin) DO NOTHING`,
|
||||
[name, tin, kebele ?? KEBELE, houseNo ?? HOUSE_NO, phone, email],
|
||||
);
|
||||
}
|
||||
|
||||
const summary = await AppDataSource.query(
|
||||
`SELECT count(*)::int AS on_file,
|
||||
count(*) FILTER (WHERE name LIKE 'Taxpayer %')::int AS seeded
|
||||
FROM freight.companies
|
||||
WHERE tin = ANY($1::text[])`,
|
||||
[MOR_TEST_BUYERS.map(([tin]) => tin)],
|
||||
);
|
||||
console.table(summary);
|
||||
console.log("Seeded MoR EIMS/BSP test buyers.");
|
||||
} finally {
|
||||
await AppDataSource.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
seedMorTestBuyers().catch((err) => {
|
||||
console.error("MoR test buyer seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user