From 0e228bfd8d242173f88b3d0751998985668bd4e6 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 17 Aug 2026 06:14:25 +0000 Subject: [PATCH 1/7] changes --- .../train-scheduling/services/train-scheduling.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 0fb6e16ab..03535135d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -1368,7 +1368,7 @@ export class TrainSchedulingService { * in the future) using the CURRENT global-rules config. Schedules already OPEN or * past their window are left untouched — customers may have booked against the * times they were shown, so those stay frozen. Returns the count re-stamped. - */ + */ async restampPendingWindows(): Promise { const cfg = await this.getWindowConfig(); const now = new Date(); From 52fb705a3e847c309166522875a71af4b44a1809 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 17 Aug 2026 06:18:16 +0000 Subject: [PATCH 2/7] changes --- .../portal/src/pages/bookings/BookingsListPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx index 4d7f3d3a0..175aec08f 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx @@ -26,7 +26,7 @@ import { LayoutList, MoreVertical, Package, - // Plus, + Search, Train, Wallet, From 562ebf485cb52359cde23fcb583bf1006f8eb5c5 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sun, 16 Aug 2026 19:15:04 +0000 Subject: [PATCH 3/7] fix(eims): thermal page-length used viewport height, not content height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scrollHeight is defined as the larger of an element's content height and its own (viewport) height — for a receipt shorter than the placeholder 1123px viewport, it silently returned the viewport height back, producing a correctly-formatted but page-length-tall PDF with a huge trailing blank strip below the real content. Found by actually rendering one and looking at it, not caught by unit tests (buildThermalHtml is pure string output, never exercises page.pdf() sizing). Fix: use a deliberately tiny (100px) viewport height for the thermal measurement pass, forcing content to overflow it so scrollHeight always reflects the receipt's real height. Also round the computed mm value before templating it into the CSS length string. Co-Authored-By: Claude Sonnet 5 --- .../modules/billing/documents/pdf-render.service.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts index 21676c160..3767637a7 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts @@ -84,7 +84,13 @@ export class PdfRenderService { const page = await browser.newPage(); const thermal = opts.thermal ?? false; const viewportWidth = thermal ? Math.round((THERMAL_PAGE_WIDTH_MM / 25.4) * 96) : 794; - await page.setViewport({ width: viewportWidth, height: 1123, deviceScaleFactor: 1 }); + // Thermal viewport height is deliberately tiny (not a real page height at all): scrollHeight + // is defined as the LARGER of the content's height and the viewport's own height, so a + // receipt shorter than the viewport would otherwise report the viewport height back, not + // its true content height — a real page-length trailing blank space bug, not theoretical + // (confirmed by actually rendering one). A short viewport forces content to overflow it, + // so scrollHeight always reflects the content, never the viewport. + await page.setViewport({ width: viewportWidth, height: thermal ? 100 : 1123, deviceScaleFactor: 1 }); await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 }); await page.emulateMediaType("print"); await new Promise((resolve) => setTimeout(resolve, 250)); @@ -154,7 +160,7 @@ export class PdfRenderService { // browser context regardless, same as the closure form would be. const scrollPx = (await page.evaluate("document.documentElement.scrollHeight")) as number; const contentMm = (scrollPx / 96) * 25.4 + THERMAL_MARGIN_MM * 2 + THERMAL_FEED_MM; - return Math.min(THERMAL_MAX_HEIGHT_MM, contentMm); + return Math.min(THERMAL_MAX_HEIGHT_MM, Math.round(contentMm * 100) / 100); } private injectPdfPrintStyles(html: string): string { From 09bc7c74d73e2262e83157ab7cb493b114f83e28 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 17 Aug 2026 07:18:39 +0000 Subject: [PATCH 4/7] feat(eims): derive buyer City/Country from company profile, not global config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit City: EimsMapperContext.buyerCity was declared but never wired anywhere — always null, silently, for every buyer. No dedicated city column on Company; derives from Zone via a new EIMS_BUYER_CITY_CODES map, same lookup mechanism as Region/Wereda but optional (an unmapped zone resolves to null rather than throwing) — MoR has already accepted a live filing with City null. Country: previously a single flat EIMS_BUYER_COUNTRY_CODE applied to every buyer regardless of Company.country. Now reads company.country, resolved via a new EIMS_BUYER_COUNTRY_CODES name-to-code map; the flat env var becomes a domestic-only fallback (applies only when country is empty/Ethiopia), so an unmapped foreign buyer fails locally instead of silently filing as Ethiopia. Co-Authored-By: Claude Sonnet 5 --- .../edr-freight-api/src/config/eims.config.ts | 22 ++++++ .../billing/eims-invoice.mapper.spec.ts | 53 ++++++++++++- .../modules/billing/eims-invoice.mapper.ts | 78 ++++++++++++++++--- .../src/modules/eims/eims-invoice-context.ts | 2 + .../src/modules/eims/eims-test-fixtures.ts | 2 + 5 files changed, 147 insertions(+), 10 deletions(-) diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index cf77072e0..c5565cbc1 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -80,7 +80,19 @@ 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; /** * 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 @@ -89,6 +101,14 @@ export interface EimsInvoiceConfig { buyerRegionCodes: Record; /** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */ buyerWeredaCodes: Record; + /** + * 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; /** * 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 @@ -208,8 +228,10 @@ export default registerAs("eims", (): EimsConfig => { 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), buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES), buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES), + buyerCityCodes: 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), diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts index 7406613fc..6d58a7bec 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts @@ -60,8 +60,11 @@ const context = (over: Partial = {}): EimsMapperContext => ({ unitDefault: "PCS", incomeWithholdValue: 0, transactionWithholdValue: 0, + buyerCountryCode: "231", // test-only, not a confirmed real MoR code + buyerCountryCodes: {}, buyerRegionCodes: { "Addis Ababa": "13" }, buyerWeredaCodes: {}, + buyerCityCodes: {}, ...over, }); @@ -92,6 +95,9 @@ describe("toEimsInvoice", () => { 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", Email: "buyer@abc.et", HouseNumber: "NEW", IdNumber: null, @@ -100,7 +106,6 @@ describe("toEimsInvoice", () => { LegalName: "ABC Trading PLC", Phone: "0912345678", Region: "13", - Country: null, Zone: "SHA", Kebele: "03", VatNumber: "123475885858", @@ -335,6 +340,52 @@ describe("toEimsInvoice — MoR field constraints", () => { ).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/); + }); + it("emits NatureOfSupplies lowercase, whatever case it was configured in", () => { const doc = toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Service" })); expect(doc.ItemList[0].NatureOfSupplies).toBe("service"); diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts index 582e3d326..406952867 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts @@ -234,8 +234,13 @@ export interface EimsMapperContext { * from a registered invoice"). */ relatedDocument?: string | null; - /** MoR numeric country code for the buyer; our DB stores the country name. */ + /** + * 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; /** * Region name → MoR numeric code, for buyers whose stored region is free text. * @@ -252,9 +257,15 @@ export interface EimsMapperContext { * fail locally on an unmapped name rather than file a guess. */ buyerWeredaCodes: Record; + /** + * 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; buyerIdType?: string | null; buyerIdNumber?: string | null; - buyerCity?: string | null; /** Required when the invoice currency is not ETB. */ exchangeRate?: number | null; invoiceDiscount?: number | null; @@ -299,17 +310,22 @@ export const formatEimsDate = (issuedAt: Date): string => * exchange rate. */ /** - * A buyer's location value (Region or Wereda) as a MoR code: passed through when already numeric, - * otherwise looked up by name (case- and space-insensitive). Throws when neither applies — sending - * a guessed code onto a tax document is worse than refusing to file. + * 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", + field: "Region" | "Wereda" | "City", value: string | null | undefined, codes: Record, envVar: string, invoiceNumber: string, -): string { + opts: { required?: boolean } = {}, +): string | null { const raw = (value ?? "").trim(); if (LOCATION_CODE.test(raw)) return raw; @@ -319,12 +335,42 @@ function resolveLocationCode( )?.[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, + 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.", + ); +} + export function toEimsInvoice( invoice: EimsMapperInvoice, seller: EimsSellerDetails, @@ -440,7 +486,16 @@ export function toEimsInvoice( return { BuyerDetails: { - City: context.buyerCity ?? null, + // 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 }, + ), Email: company.email ?? null, HouseNumber: company.houseNo ?? null, IdNumber: context.buyerIdNumber ?? null, @@ -455,7 +510,12 @@ export function toEimsInvoice( "EIMS_BUYER_REGION_CODES", invoice.invoiceNumber, ), - Country: context.buyerCountryCode ?? null, + Country: resolveCountryCode( + company.country, + context.buyerCountryCodes, + context.buyerCountryCode ?? null, + invoice.invoiceNumber, + ), Zone: company.zone ?? null, Kebele: company.kebele ?? null, VatNumber: company.vatNumber ?? null, diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts index b77804eda..4c1c82c10 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts @@ -206,8 +206,10 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E incomeWithholdValue: invoice.incomeWithholdValue!, transactionWithholdValue: invoice.transactionWithholdValue!, buyerCountryCode: invoice.buyerCountryCode, + buyerCountryCodes: invoice.buyerCountryCodes, buyerRegionCodes: invoice.buyerRegionCodes, buyerWeredaCodes: invoice.buyerWeredaCodes, + buyerCityCodes: invoice.buyerCityCodes, // TEMPORARY — see EimsInvoiceConfig.buyerIdType. buyerIdType: invoice.buyerIdType, buyerIdNumber: invoice.buyerIdNumber, diff --git a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts index 93df29da2..650a834b8 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts @@ -33,8 +33,10 @@ export const eimsInvoiceConfig = (over: Partial = {}): EimsIn 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: {}, From 28c9dd93e07286e21c187e5ead39023e72c0b6d8 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 17 Aug 2026 07:40:40 +0000 Subject: [PATCH 5/7] feat(eims): seller identity enriched from e-Trade, cached (bootstrap only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten EIMS_SELLER_* env vars were the only source of EDR's own seller identity, duplicating data the platform already has via the same e-Trade lookup used for every customer company at onboarding. EimsSellerCacheService now enriches it — but static config remains the source of truth: MoR validates SellerDetails against its own taxpayer registry (rule 7017, already cleared against the current static values), so e-Trade fills a field only when the static value is blank, never overrides one already confirmed. The static config is therefore the durable fallback, not the cache; an in-memory snapshot lost on restart is harmless. ETradeService has no request timeout of its own and no AbortController, so the cache enforces one locally (stops waiting, doesn't cancel the request) and de-duplicates concurrent refresh() calls into the same in-flight promise. getSellerDetails() is fully synchronous — zero I/O — so live registration never depends on e-Trade being reachable, at boot or per invoice. VatNumber and Email stay on static config permanently — confirmed by reading e-Trade's actual response shapes, neither field exists anywhere in what it returns. Region/Wereda/City reuse the existing EIMS_BUYER_*_CODES maps rather than adding seller-specific ones — the geography is objective, not buyer-specific. Co-Authored-By: Claude Sonnet 5 --- .../modules/billing/eims-invoice.mapper.ts | 19 +++ .../src/modules/companies/companies.module.ts | 3 + .../eims-invoice-registration.service.spec.ts | 5 + .../eims/eims-invoice-registration.service.ts | 10 +- .../eims/eims-seller-cache.service.spec.ts | 155 ++++++++++++++++++ .../modules/eims/eims-seller-cache.service.ts | 147 +++++++++++++++++ .../src/modules/eims/eims.module.ts | 7 + 7 files changed, 340 insertions(+), 6 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts index 406952867..b8755c709 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts @@ -371,6 +371,25 @@ function resolveCountryCode( ); } +/** + * 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 | 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, diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index b990aa8f0..666634cb8 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -67,6 +67,9 @@ import { VerifaydaModule } from "../verifayda/verifayda.module"; // Consumed by NotificationInboxModule for portal recipient targeting. ExternalProfileRepository, CompanyProfileRepository, + // Consumed by EimsModule's EimsSellerCacheService — same e-Trade business-registry lookup + // already used for every customer company at onboarding, reused for EDR's own TIN. + ETradeService, ], }) export class CompaniesModule { } diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts index 36c355bed..79d3204dd 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts @@ -11,7 +11,9 @@ import { NotificationsService } from "../notifications/notifications.service"; import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; import { EimsApiException } from "./eims.errors"; +import { buildEimsSeller } from "./eims-invoice-context"; import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; +import { EimsSellerCacheService } from "./eims-seller-cache.service"; import { EimsSystemState } from "./entities/eims-system-state.entity"; import { EimsInvoiceStatus } from "./eims-registration.types"; @@ -172,6 +174,9 @@ const build = ( } as unknown as EimsAuthService, { notify } as unknown as NotificationInboxService, { directSend } as unknown as NotificationsService, + // Same static-config seller the real EimsSellerCacheService falls back to when it has never + // successfully fetched e-Trade — matches prior behavior for every test in this file. + { getSellerDetails: (c: EimsConfig) => buildEimsSeller(c) } as unknown as EimsSellerCacheService, ); /** 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 ab9f953c5..2ca5f5ffd 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 @@ -27,12 +27,9 @@ import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; import { EimsApiException } from "./eims.errors"; +import { EimsSellerCacheService } from "./eims-seller-cache.service"; import { EimsSystemState } from "./entities/eims-system-state.entity"; -import { - assertEimsInvoiceConfig, - buildEimsContext, - buildEimsSeller, -} from "./eims-invoice-context"; +import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context"; import { EimsInvoiceError, EimsInvoiceStatus, @@ -83,6 +80,7 @@ export class EimsInvoiceRegistrationService { private readonly auth: EimsAuthService, private readonly inbox: NotificationInboxService, private readonly notifications: NotificationsService, + private readonly sellerCache: EimsSellerCacheService, ) {} private get cfg(): EimsConfig { @@ -128,7 +126,7 @@ export class EimsInvoiceRegistrationService { // The request can only be built now: InvoiceCounter and PreviousIrn come from the reservation. const request = toEimsInvoice( invoice, - buildEimsSeller(cfg), + this.sellerCache.getSellerDetails(cfg), buildEimsContext(cfg, { // 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. diff --git a/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.spec.ts new file mode 100644 index 000000000..7b4ff1b3f --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.spec.ts @@ -0,0 +1,155 @@ +import { ConfigService } from "@nestjs/config"; + +import { EimsConfig } from "../../config/eims.config"; +import { ETradeService } from "../companies/services/etrade.service"; +import { eimsConfig, eimsInvoiceConfig } from "./eims-test-fixtures"; +import { EimsSellerCacheService } from "./eims-seller-cache.service"; + +const registrationData = (over: Record = {}) => ({ + companyName: "Ethio-Djibouti Railway PLC (eTrade)", + region: "Addis Ababa", + zone: "Bole", + woreda: "Yeka", + mobilePhone: "0911000000", + regularPhone: "", + ...over, +}); + +const build = (cfg: EimsConfig = eimsConfig()) => { + const resolveCompanyData = jest.fn(); + const extractRegistrationData = jest.fn().mockReturnValue(registrationData()); + const etrade = { resolveCompanyData, extractRegistrationData } as unknown as ETradeService; + const config = { get: () => cfg } as unknown as ConfigService; + const service = new EimsSellerCacheService(etrade, config); + 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 }), + }); + const { service, resolveCompanyData } = build(cfg); + resolveCompanyData.mockResolvedValue({ + companyInfo: {}, + businessInfo: {}, // presence is all that matters — extractRegistrationData is mocked + }); + + await service.refresh(); + const seller = service.getSellerDetails(cfg); + + // The static sellerLegalName ("Ethio-Djibouti Railway S.C.") must survive, not e-Trade's + // differently-punctuated "Ethio-Djibouti Railway PLC (eTrade)". + expect(seller.LegalName).toBe("Ethio-Djibouti Railway S.C."); + }); + + it("e-Trade fills a field only when the static value is blank", async () => { + const cfg = eimsConfig({ + invoice: eimsInvoiceConfig({ + sellerLegalName: "", + sellerRegion: "", + sellerWereda: "", + sellerCity: null, + ...CODES, + }), + }); + const { service, resolveCompanyData } = build(cfg); + resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} }); + + await service.refresh(); + const seller = service.getSellerDetails(cfg); + + expect(seller.LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)"); + expect(seller.Region).toBe("13"); + expect(seller.Wereda).toBe("99"); + 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 }), + }); + const { service, resolveCompanyData } = build(cfg); + resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} }); + + await service.refresh(); + const seller = service.getSellerDetails(cfg); + + expect(seller.VatNumber).toBe("0000000000"); + expect(seller.Email).toBe("finance@example.et"); + }); + + it("falls back to the static config entirely when e-Trade has never been reachable", () => { + const cfg = eimsConfig(); + const { service } = build(cfg); + + // No refresh() ever called/succeeded — cached stays null. + const seller = service.getSellerDetails(cfg); + + expect(seller.LegalName).toBe(cfg.invoice.sellerLegalName); + expect(seller.Region).toBe(cfg.invoice.sellerRegion); + }); + + it("does no I/O at all — filing never triggers an e-Trade request", () => { + const { service, resolveCompanyData, cfg } = build(); + + service.getSellerDetails(cfg); + service.getSellerDetails(cfg); + + expect(resolveCompanyData).not.toHaveBeenCalled(); + }); +}); + +describe("EimsSellerCacheService.refresh", () => { + it("keeps the previous snapshot when a refresh fails", async () => { + const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) }); + const { service, resolveCompanyData } = build(cfg); + resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} }); + await service.refresh(); + expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)"); + + resolveCompanyData.mockRejectedValueOnce(new Error("eTrade down")); + await service.refresh(); + + expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)"); + }); + + 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 { service, resolveCompanyData } = build(cfg); + resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} }); + await service.refresh(); + expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)"); + + resolveCompanyData.mockReturnValueOnce(new Promise(() => {})); // never resolves + const refreshing = service.refresh(); + await jest.advanceTimersByTimeAsync(10_000); + await refreshing; + + expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)"); + } finally { + jest.useRealTimers(); + } + }); + + it("does not start a second e-Trade request while one is already in flight", async () => { + const { service, resolveCompanyData } = build(); + let resolveCall: (value: unknown) => void = () => {}; + resolveCompanyData.mockReturnValue(new Promise((resolve) => (resolveCall = resolve))); + + const first = service.refresh(); + const second = service.refresh(); + resolveCall({ companyInfo: {}, businessInfo: {} }); + await Promise.all([first, second]); + + expect(resolveCompanyData).toHaveBeenCalledTimes(1); + }); +}); 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 new file mode 100644 index 000000000..a8d242929 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts @@ -0,0 +1,147 @@ +import { Injectable, Logger, OnModuleInit } from "@nestjs/common"; +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 { buildEimsSeller } from "./eims-invoice-context"; + +const has = (value: string | null | undefined): value is string => Boolean(value && value.trim()); + +/** + * EDR's own EIMS seller identity (LegalName/Phone/Region/Wereda/City), enriched from the same + * e-Trade business-registry lookup already used for every customer company at onboarding — instead + * of the whole thing being hand-maintained `EIMS_SELLER_*` config. + * + * **Static config is the source of truth, e-Trade is bootstrap/enrichment only.** MoR validates + * `SellerDetails` against its own taxpayer registry (rule 7017, already cleared and live-tested + * with the current static values) — e-Trade filling a gap is fine, e-Trade silently overriding a + * value already confirmed against MoR is not. `getSellerDetails` therefore only reaches for the + * e-Trade-derived value when the static one is blank; a static value, once set, is never replaced. + * This also means the durable fallback is the static config, not this cache — the in-memory + * snapshot disappearing on a process restart is harmless, not a reliability gap: every field it + * could supply already has a working static value today, so filing is unaffected either way. + * + * `VatNumber` and `Email` are never sourced here — confirmed by reading e-Trade's actual response + * shapes (`ETradeCompanyInfo`, `ETradeBusinessInfo`, `CompanyRegistrationData`): neither field + * exists anywhere in what e-Trade returns. They stay on static config permanently, same as + * `SubCity`/`Locality`/`HouseNumber`, which this pass doesn't touch. + * + * Cache shape follows `PositionTypePermissionsCache`'s precedent (`src/common/ + * position-type-permissions.cache.ts`) for "external/slow data, not fetched per request": a plain + * field refreshed on a raw `setInterval`, `unref()`'d so it never holds the process open, and a + * refresh failure keeps serving the previous snapshot rather than clearing it. Two deliberate + * deviations from that precedent, both because `ETradeService` has no request timeout configured + * at all (confirmed by reading it) and is a third-party dependency, unlike the DB: + * - the first fetch is fire-and-forget in `onModuleInit`, never awaited by boot; + * - `refresh()` is wrapped in a local timeout, and a second call while one is already in flight + * returns the same in-flight promise instead of starting a duplicate request. + * + * `getSellerDetails` is fully synchronous — zero I/O at call time — so a live invoice registration + * never depends on e-Trade being reachable at that moment, whether or not it ever has been. + */ +@Injectable() +export class EimsSellerCacheService implements OnModuleInit { + private readonly logger = new Logger(EimsSellerCacheService.name); + + /** Only the e-Trade-derived fields, used solely to fill a blank static value. */ + private cached: Partial | null = null; + /** Concurrency guard — a second `refresh()` call while one is running joins it. */ + private refreshing: Promise | null = null; + + // ponytail: daily refresh, no invalidation hook — a change at e-Trade takes up to 24h to reach a + // filed invoice. Wire a manual refresh() call (e.g. from an admin action) if that lag ever + // matters; EDR's own business registration changes rarely enough that this is a generous + // ceiling, not a real one. + private static readonly REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000; + /** Bounded locally since `ETradeService` itself sets none — see the class comment. */ + private static readonly REFRESH_TIMEOUT_MS = 10_000; + + constructor( + private readonly etrade: ETradeService, + private readonly config: ConfigService, + ) {} + + onModuleInit(): void { + void this.refresh(); + const timer = setInterval(() => void this.refresh(), EimsSellerCacheService.REFRESH_INTERVAL_MS); + timer.unref?.(); + } + + /** + * Static config wins whenever it's non-blank — that's the value already confirmed against MoR. + * e-Trade fills a field only when the static one is empty. Synchronous, no I/O: safe to call on + * every registration. + */ + getSellerDetails(cfg: EimsConfig): EimsSellerDetails { + const fallback = buildEimsSeller(cfg); + const e = this.cached; + return { + ...fallback, + LegalName: has(fallback.LegalName) ? fallback.LegalName : (e?.LegalName ?? fallback.LegalName), + Phone: has(fallback.Phone) ? fallback.Phone : (e?.Phone ?? fallback.Phone), + Region: has(fallback.Region) ? fallback.Region : (e?.Region ?? fallback.Region), + Wereda: has(fallback.Wereda) ? fallback.Wereda : (e?.Wereda ?? fallback.Wereda), + City: has(fallback.City) ? fallback.City : (e?.City ?? fallback.City), + }; + } + + /** Reload the cache. Concurrency-safe (see class comment); public so a caller can force one. */ + async refresh(): Promise { + if (this.refreshing) return this.refreshing; + this.refreshing = this.doRefresh().finally(() => { + this.refreshing = null; + }); + return this.refreshing; + } + + private async doRefresh(): Promise { + try { + const cfg = this.config.get("eims")!; + const { companyInfo, businessInfo } = await this.withTimeout( + this.etrade.resolveCompanyData(cfg.tin), + EimsSellerCacheService.REFRESH_TIMEOUT_MS, + ); + if (!businessInfo) return; // no licence on file yet — keep the previous snapshot + const data = this.etrade.extractRegistrationData(businessInfo, companyInfo); + const codes = cfg.invoice; + 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), + }; + } catch (err) { + this.logger.warn( + `EIMS seller e-Trade refresh failed, keeping previous snapshot: ${(err as Error).message}`, + ); + } + } + + /** + * `ETradeService` sets no request timeout of its own, so one is enforced here. Note this only + * stops *waiting* on the request — nothing cancels the underlying HTTP call (no + * `AbortController` wired into `ETradeService`), so a timed-out request may still complete in + * the background; its result is simply never read. + */ + private withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`e-Trade lookup timed out after ${ms}ms`)), ms); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (err) => { + clearTimeout(timer); + reject(err); + }, + ); + }); + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims.module.ts b/apps/edr-freight-api/src/modules/eims/eims.module.ts index 0ee22ec88..5d55a7ee8 100644 --- a/apps/edr-freight-api/src/modules/eims/eims.module.ts +++ b/apps/edr-freight-api/src/modules/eims/eims.module.ts @@ -4,6 +4,7 @@ import { TypeOrmModule } from "@nestjs/typeorm"; import { Invoice } from "../billing/entities/invoice.entity"; import { DocumentsModule } from "../billing/documents/documents.module"; +import { CompaniesModule } from "../companies/companies.module"; import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module"; import { NotificationsModule } from "../notifications/notifications.module"; import { EimsAuthService } from "./eims-auth.service"; @@ -14,6 +15,7 @@ import { EimsCredentialsProvider } from "./eims-credentials.provider"; import { EimsInvoiceController } from "./eims-invoice.controller"; import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; import { EimsReceiptService } from "./eims-receipt.service"; +import { EimsSellerCacheService } from "./eims-seller-cache.service"; import { EimsSignerService } from "./eims-signer.service"; import { EimsReceipt } from "./entities/eims-receipt.entity"; import { EimsSystemState } from "./entities/eims-system-state.entity"; @@ -33,6 +35,10 @@ import { EimsSystemState } from "./entities/eims-system-state.entity"; // For EimsReceiptService.document() — the shared sealed invoice/receipt PDF layout. No domain // deps of its own (StampSettingsService/LogoSettingsService are both @Global), so no cycle. DocumentsModule, + // For EimsSellerCacheService's ETradeService — CompaniesModule has a forwardRef cycle with + // ShippingLineCompaniesModule -> BillingModule, but nothing in that chain imports EimsModule, + // so this stays a plain one-directional import, not a new cycle. + CompaniesModule, ], controllers: [EimsInvoiceController], providers: [ @@ -44,6 +50,7 @@ import { EimsSystemState } from "./entities/eims-system-state.entity"; EimsAutoSubmitService, EimsCancellationService, EimsReceiptService, + EimsSellerCacheService, ], exports: [ EimsAuthService, From c3c3d08a41293be0bb1db057b61630dd48ad30ad Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 17 Aug 2026 07:55:52 +0000 Subject: [PATCH 6/7] fix issue --- .../booking-clearance.service.spec.ts | 1 + .../contracts/booking-clearance.service.ts | 39 +++++++- .../contracts/ContractClearanceListPage.tsx | 58 ++++++++++-- .../contracts/GlDjiboutiClearanceListPage.tsx | 32 ++++--- .../contracts/contract-clearance-table.css | 94 +++++++++++++++++++ .../src/pages/invoices/InvoicesPage.tsx | 70 +++++++------- .../components/WagonCancellationCard.tsx | 4 +- 7 files changed, 238 insertions(+), 60 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/pages/contracts/contract-clearance-table.css diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts index 8c127e840..b29a4cfa5 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts @@ -110,6 +110,7 @@ function makeService(overrides?: { .fn() .mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }), } as never, // transit agents + { findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository ); return { diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index e0d30a2be..43176da75 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -1,4 +1,5 @@ import { BadRequestException, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; import { ContractDocPhase, isDeliveryOrderFileCode, @@ -29,6 +30,7 @@ import { ClearanceMilestoneService } from './clearance-milestone.service'; import { GlOperationsService } from './gl-operations.service'; import { GlExchangeService } from './gl-exchange.service'; import { TransitAgentsService } from '../transit-agents/transit-agents.service'; +import { ContractsRepository } from './contracts.repository'; import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util'; @@ -155,6 +157,7 @@ export class BookingClearanceService { private readonly notifier: BookingLifecycleNotifierService, private readonly glExchangeService: GlExchangeService, private readonly transitAgentsService: TransitAgentsService, + private readonly contractsRepository: ContractsRepository, ) {} private async assertPhasedCustoms(booking: Booking): Promise { @@ -988,7 +991,39 @@ export class BookingClearanceService { const milestones = await this.workflowService.listMilestonesForBooking(b.id); if (belongsOnEtClearanceQueue(milestones)) filtered.push(b); } - return filtered; + return this.attachContractSummary(filtered); + } + + /** + * Queue rows show the parent contract's reference and lane. Booking has no + * contract relation, and a bare initiated instance may not carry yards yet — + * so batch-load the contracts (with routes) and fill in what's missing: + * `contractReference` always, origin/destination yards only when the booking + * lacks them (its own route wins). + */ + private async attachContractSummary(bookings: Booking[]): Promise { + const ids = [...new Set(bookings.map((b) => b.contractId).filter(Boolean))] as string[]; + if (!ids.length) return bookings; + const contracts = await this.contractsRepository.findAll({ + where: { id: In(ids) }, + relations: { routes: { originYard: true, destinationYard: true } }, + }); + const byId = new Map(contracts.map((c) => [c.id, c])); + for (const b of bookings) { + const contract = b.contractId ? byId.get(b.contractId) : undefined; + if (!contract) continue; + const row = b as Booking & { contractReference?: string | null }; + row.contractReference = contract.reference ?? null; + if (b.originYard && b.destinationYard) continue; + const routes = contract.routes ?? []; + const route = + routes.find((r) => r.id === b.contractRouteId) ?? + (routes.length === 1 ? routes[0] : undefined); + if (!route) continue; + b.originYard = b.originYard ?? route.originYard; + b.destinationYard = b.destinationYard ?? route.destinationYard; + } + return bookings; } async djQueue(): Promise { @@ -1008,6 +1043,6 @@ export class BookingClearanceService { filtered.push(b); } } - return filtered; + return this.attachContractSummary(filtered); } } diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx index 8719f644a..503e6dc60 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx @@ -52,6 +52,47 @@ import { summarizeRequestedCargo, } from "@/features/clearance/requestedCargo"; import { contractsService } from "@/services/contracts.service"; +import "./contract-clearance-table.css"; + +/** Yards carry `label` (API) — older shapes used `name`/`code`. */ +function yardLabel( + yard?: { label?: string; code?: string; name?: string } | null, +): string { + if (!yard) return "—"; + return yard.label ?? yard.name ?? yard.code ?? "—"; +} + +/** + * "Origin → Destination", wrapping past 120px as "Addis Ababa" / + * "→ Djibouti": the arrow is glued to the destination with an nbsp, and + * text wraps normally (the table's cells are otherwise nowrap) so a long + * lane never spills into the next column. + */ +function RouteLabel({ + origin, + destination, +}: { + origin: string; + destination: string; +}) { + return ( + + {origin}{" "} + + {"\u00A0"} + {destination} + + ); +} function CustomsBadge({ customs }: { customs: boolean }) { return customs ? ( @@ -118,8 +159,8 @@ export default function ContractClearanceListPage() { id: b.id, reference: b.reference, customerLabel: b.company?.name ?? b.governmentInstitution ?? "—", - originLabel: b.originYard?.name ?? "—", - destinationLabel: b.destinationYard?.name ?? "—", + originLabel: yardLabel(b.originYard), + destinationLabel: yardLabel(b.destinationYard), tradeDirection: b.tradeDirection ?? "—", freightType: b.freightType ?? "—", status: b.status, @@ -430,11 +471,10 @@ function ShipmentBookingsTable({ id: "route", header: () => Route, cell: ({ row }) => ( - - {row.original.originLabel} - - {row.original.destinationLabel} - + ), }, { @@ -600,13 +640,13 @@ function ShipmentBookingsTable({ } return ( - + columns={columns} data={rows} status={loading ? "loading" : error ? "error" : "success"} onRowClick={(row) => onOpen(row.id)} - containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words" + containerClassName="edr-clearance-table border-0 shadow-none rounded-none bg-transparent" /> ); diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx index 76f2bc5b0..6e34da29a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx @@ -45,6 +45,7 @@ import { KpiStrip } from "@/components/page/KpiStrip"; import { bookingTable } from "@/components/bookings/booking-ui.styles"; import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings"; import type { BookingDetail } from "@/types/booking"; +import "./contract-clearance-table.css"; const prettyStatus = (s?: string | null) => (s ?? "") @@ -214,15 +215,24 @@ function RouteCell({ }) { return ( - - - {origin} - - - - {destination} - - + {/* Wraps past 120px as "Addis Ababa" / "→ Djibouti"; text wraps + normally (cells are otherwise nowrap) so it never spills over. */} + + {origin}{" "} + + {"\u00A0"} + {destination} + @@ -676,7 +686,7 @@ export default function GlDjiboutiClearanceListPage() { ) : null} ) : ( - + columns={shipmentColumns} data={pagedShipmentRows} @@ -694,7 +704,7 @@ export default function GlDjiboutiClearanceListPage() { manualPagination: true, pageCount, }} - containerClassName="border-0 shadow-none bg-transparent [&_th]:max-w-[100px] [&_td]:max-w-[100px] [&_td]:break-words" + containerClassName="edr-clearance-table border-0 shadow-none rounded-none bg-transparent" footer={DataTableFooter} /> diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/contract-clearance-table.css b/apps/edr-freight-web/backoffice/src/pages/contracts/contract-clearance-table.css new file mode 100644 index 000000000..bdc615fb8 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/contract-clearance-table.css @@ -0,0 +1,94 @@ +/* + * Scoped to .edr-clearance-table — the DataTable container div on the + * Document Clearance hubs (GL Ethiopia + GL Djibouti). Mirrors the portal's /bookings table + * (bookings-table.css): content-sized columns with a 100px floor, no + * truncation, horizontal scroll when the table outgrows the card, sticky + * header row and a sticky shadowed action column. + */ +.edr-clearance-table { + overflow-x: auto; + max-width: 100%; + min-width: 0; +} + +/* + * width: max-content — the table is exactly as wide as its columns' content + * needs, never squeezed to fit the viewport; the container scrolls instead. + * min-width: 100% keeps it filling the card when content is narrow. + */ +.edr-clearance-table table { + table-layout: auto; + width: max-content; + min-width: 100%; +} + +/* 100px floor, no ceiling: cells grow to fit their text, nothing is clipped. */ +.edr-clearance-table th, +.edr-clearance-table td:not([colspan]) { + min-width: 100px; + max-width: none; + overflow: visible; + text-overflow: clip; + white-space: nowrap; +} + +/* + * Mantine Badge caps itself at max-width: 100%; inside an auto-layout table + * cell that resolves against min-content and clips the label. Let badges size + * to their text so the column grows to fit them. + */ +.edr-clearance-table .mantine-Badge-root { + max-width: none; +} + +/* + * Mantine Group's preventGrowOverflow caps every child at 100%/N of the cell. + * In an auto-width table cell that resolves against min-content and collapses + * the badges/text in the Type, Route and Status columns to nothing. Let group + * children size to their content; the column grows and the container scrolls. + */ +.edr-clearance-table .mantine-Group-root > * { + max-width: none; + flex-shrink: 0; +} + +/* Sticky header row. */ +.edr-clearance-table thead th { + position: sticky; + top: 0; + z-index: 1; +} + +/* + * Sticky action column, shrunk to its content. The width overrides the inline + * width DataTable stamps from tanstack's column size — hence !important. + * `:not([colspan])` keeps the full-width error/empty rows out. + */ +.edr-clearance-table th:last-child, +.edr-clearance-table td:last-child:not([colspan]) { + width: 1% !important; + min-width: 0; + position: sticky; + right: 0; + box-shadow: -12px 0 16px -6px rgba(16, 32, 47, 0.3); +} + +/* + * Sticky cells sit above the scrolling ones, so they need their own opaque + * background or the columns underneath show through. + */ +.edr-clearance-table td:last-child:not([colspan]) { + background: #f5f8fb; + z-index: 2; +} + +/* Row hover uses the tailwind `hover:bg-accent` class on the . */ +.edr-clearance-table tbody tr:hover td:last-child:not([colspan]) { + background: var(--accent, #f4fbf8); +} + +/* Header cell is sticky on both axes — it must outrank the body's sticky column. */ +.edr-clearance-table th:last-child { + background: #f4f7fa; + z-index: 3; +} diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx index 99f3c5d23..159157e2f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx @@ -5,14 +5,20 @@ import { Card, Group, SegmentedControl, - SimpleGrid, Stack, Text, TextInput, } from "@mantine/core"; import { useDebouncedValue } from "@mantine/hooks"; import { useQuery } from "@tanstack/react-query"; -import { RefreshCw, Search, X } from "lucide-react"; +import { + Banknote, + CircleDollarSign, + Landmark, + RefreshCw, + Search, + X, +} from "lucide-react"; import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; @@ -22,6 +28,7 @@ import { formatMoney, humanize, } from "@/components/customers"; +import { KpiStrip } from "@/components/page"; import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions"; import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings"; import { api } from "@/services/api"; @@ -83,7 +90,7 @@ export default function InvoicesPanel() { // Summary card: total collected (paidAmount) across every invoice matching // the current search/status filters, not just the visible page. - const { data: summary } = useQuery( + const { data: summary, isLoading: summaryLoading } = useQuery( api.invoices.collectedSummary.queryOptions({ input: { filter: { search: debouncedQuery, status: statusFilter || undefined }, @@ -190,39 +197,30 @@ export default function InvoicesPanel() { return ( - - - - Total collected - - - {etbFromUsd !== null - ? formatMoney(etbCollected + etbFromUsd, "ETB") - : formatMoney(etbCollected, "ETB")} - - - {etbFromUsd !== null - ? `Includes ${formatMoney(usdCollected, "USD")} converted @ ${rate} ETB/USD` - : "USD rate unavailable — ETB collected only"} - - - - - Collected — ETB only - - - {formatMoney(etbCollected, "ETB")} - - - - - Collected — USD only - - - {formatMoney(usdCollected, "USD")} - - - + diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonCancellationCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonCancellationCard.tsx index f75972f28..29ec6705d 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonCancellationCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonCancellationCard.tsx @@ -235,7 +235,7 @@ export function WagonCancellationCard({ Wagon Cancellation - {canRequest && !openRow && !creditRow && ( + {/* {canRequest && !openRow && !creditRow && ( - )} + )} */} {openRow ? ( From df488ebfaa78587b5c9f067cdaff98a5f1336fc3 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 17 Aug 2026 08:01:29 +0000 Subject: [PATCH 7/7] chnages --- .../BookingDetailPage/components/WagonCancellationCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonCancellationCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonCancellationCard.tsx index 29ec6705d..d5ab1754a 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonCancellationCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonCancellationCard.tsx @@ -11,7 +11,7 @@ import { Textarea, } from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { CheckCircle2, Clock, CreditCard, TrainTrack } from "lucide-react"; +import { CheckCircle2, Clock, CreditCard } from "lucide-react"; import { useMemo, useState } from "react"; import toast from "react-hot-toast"; import { Link, useNavigate } from "react-router-dom";