mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
feat(eims): resolve buyer geography from the MoR location master
BuyerDetails Country/Region/City/Wereda now resolve from the Ministry's own EIMS_COUNTRY_REGION_VW master instead of the EIMS_BUYER_*_CODES env maps and the ethiopia-geo-codes table. Both invented their codes and looked names up globally, so KERSA/GORO/BABILE/BURE — each present in several zones with different LOCALITY_NOs — could be filed against the wrong jurisdiction. Resolution is hierarchical and refuses to guess: an unknown or ambiguous address raises a local validation error naming the level that failed, and never selects the first matching row. Spelling differences between EDR and MoR live in a reviewed, parent-scoped alias layer; the dataset itself stays verbatim so it remains traceable to the Ministry sheet. Resolution now runs before the counter reservation in both the single and bulk paths, so a bad company address no longer burns an EIMS sequence number. Adds eims:import-locations to regenerate the dataset from a future workbook, reporting duplicate rows and same-hierarchy code conflicts.
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "" } });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user