diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 79e350cc7..f36b36382 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -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", diff --git a/apps/edr-freight-api/src/config/mor-location.resolver.spec.ts b/apps/edr-freight-api/src/config/mor-location.resolver.spec.ts index 0fc2607e2..77d230583 100644 --- a/apps/edr-freight-api/src/config/mor-location.resolver.spec.ts +++ b/apps/edr-freight-api/src/config/mor-location.resolver.spec.ts @@ -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, RegExp]> = [ ["unknown country", { ...JIJIGA, country: "Wakanda" }, /no MoR COUNTRY_NAME match/], diff --git a/apps/edr-freight-api/src/config/mor-location.resolver.ts b/apps/edr-freight-api/src/config/mor-location.resolver.ts index 72459db9b..0b0cef6e2 100644 --- a/apps/edr-freight-api/src/config/mor-location.resolver.ts +++ b/apps/edr-freight-api/src/config/mor-location.resolver.ts @@ -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). */ diff --git a/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.ts index 875349b6a..e8c111b62 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.ts @@ -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 }; }); diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts index d5c987779..b705901d6 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts @@ -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 diff --git a/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts index 7afe644a1..ca5cb6a30 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts @@ -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 diff --git a/apps/edr-freight-api/src/scripts/seed-mor-test-buyers.ts b/apps/edr-freight-api/src/scripts/seed-mor-test-buyers.ts new file mode 100644 index 000000000..25ae2f038 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-mor-test-buyers.ts @@ -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 { + 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); +});