diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 79e350cc7..f36b36382 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -32,6 +32,7 @@ "seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts", "seed:dropdown-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-dropdown-settings.ts", "seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts", + "seed:mor-test-buyers": "ts-node -r tsconfig-paths/register src/scripts/seed-mor-test-buyers.ts", "seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh", "iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js", "iam:migration:run": "pnpm run iam:typeorm:cli migration:run", diff --git a/apps/edr-freight-api/src/config/mor-location.resolver.spec.ts b/apps/edr-freight-api/src/config/mor-location.resolver.spec.ts index 0fc2607e2..77d230583 100644 --- a/apps/edr-freight-api/src/config/mor-location.resolver.spec.ts +++ b/apps/edr-freight-api/src/config/mor-location.resolver.spec.ts @@ -29,6 +29,8 @@ const FIXTURE: MorLocationTuple[] = [ [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 976, "Wal-Mera"], [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 909, "Akaki woreda"], [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1100, "WOREDA 1"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1102, "WOREDA 3"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1139, "WOREDA 7"], [253, "Djibouti", 1, "DJIBOUTI", 1, "DJIBOUTI VILLE", 1, "BALBALA"], ]; @@ -181,6 +183,93 @@ describe("resolveMorGeo", () => { }); }); + describe("Addis Ababa, where MoR has no zone tier", () => { + // e-Trade's real shape for a chartered city: `zone` repeats the region, the sub-city sits in + // `woreda`, and the numbered woreda sits in `kebele`. This is how every company imported from + // e-Trade stores an Addis Ababa address, and it is the shape that blocked INV-20260829-00011. + const ETRADE_SHAPE = { + country: "Ethiopia", + region: "Addis Ababa", + zone: "Addis Ababa", + woreda: "Kolfe-Keraniyo", + kebele: "07", + }; + + it("reads the sub-city and woreda one level down when the zone repeats the region", () => { + expect(resolveMorGeo(ETRADE_SHAPE, FIXTURE)).toEqual({ + Country: "70", + Region: "13", + City: "81", + Wereda: "1139", + }); + }); + + it("matches MoR's own 'KOLFIE KERANIYO' spelling of the sub-city", () => { + expect(resolveMorGeo({ ...ETRADE_SHAPE, woreda: "Kolfe Keranio" }, FIXTURE).City).toBe("81"); + }); + + it("reads a zero-padded number as MoR's 'WOREDA n' locality, in either slot", () => { + const bole = { country: "Ethiopia", region: "Addis Ababa", zone: "Bole" }; + expect(resolveMorGeo({ ...bole, woreda: "03" }, FIXTURE).Wereda).toBe("1102"); + expect(resolveMorGeo({ ...bole, woreda: "Woreda 03" }, FIXTURE).Wereda).toBe("1102"); + expect(resolveMorGeo({ ...bole, woreda: "WOREDA 3" }, FIXTURE).Wereda).toBe("1102"); + }); + + it("still resolves the already-correct shape without shifting", () => { + expect( + resolveMorGeo( + { country: "Ethiopia", region: "ADDIS ABABA", zone: "BOLE", woreda: "WOREDA 1" }, + FIXTURE, + ), + ).toEqual({ Country: "70", Region: "13", City: "78", Wereda: "1100" }); + }); + + it("reports the zone failure, not the shifted one, when the shift does not resolve", () => { + // LEMI KURA is a 2020 sub-city the Ministry sheet does not list. The shift must not turn + // that into a confusing locality error, and must never land on a neighbouring sub-city. + expect(() => + resolveMorGeo({ ...ETRADE_SHAPE, woreda: "Lemi Kura", kebele: "02" }, FIXTURE), + ).toThrow(/no MoR CITY_NAME match for country="Ethiopia", region="Addis Ababa"/); + }); + + it("does not shift when the zone is simply an unknown zone", () => { + expect(() => + resolveMorGeo( + { + country: "Ethiopia", + region: "OROMIA", + zone: "East Zone", + woreda: "KERSA", + kebele: "01", + }, + FIXTURE, + ), + ).toThrow(/no MoR CITY_NAME match/); + }); + }); + + it("resolves the regions and zones MoR spells differently from e-Trade", () => { + // Guards the reviewed alias table: MoR's PARISH_NAME is "AMAHARA", and it keeps the Amharic + // compass words for the Oromia zones ("MISRAK SHOA" for East Shewa). + const rows: MorLocationTuple[] = [ + ...FIXTURE, + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 21, "ADAMA"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 149, "MECHA"], + ]; + expect( + resolveMorGeo( + { country: "Ethiopia", region: "Oromia", zone: "East Shewa", woreda: "Adama" }, + rows, + ), + ).toEqual({ Country: "70", Region: "2", City: "16", Wereda: "21" }); + expect( + resolveMorGeo( + { country: "Ethiopia", region: "Amhara", zone: "West Gojjam", woreda: "Mecha" }, + rows, + ), + ).toEqual({ Country: "70", Region: "11", City: "53", Wereda: "149" }); + }); + describe("failures happen locally, before anything is filed", () => { const cases: Array<[string, Record, RegExp]> = [ ["unknown country", { ...JIJIGA, country: "Wakanda" }, /no MoR COUNTRY_NAME match/], diff --git a/apps/edr-freight-api/src/config/mor-location.resolver.ts b/apps/edr-freight-api/src/config/mor-location.resolver.ts index 72459db9b..0b0cef6e2 100644 --- a/apps/edr-freight-api/src/config/mor-location.resolver.ts +++ b/apps/edr-freight-api/src/config/mor-location.resolver.ts @@ -43,6 +43,11 @@ export interface MorAddressInput { region?: string | null; zone?: string | null; woreda?: string | null; + /** + * Only read for the city-region shift below — in Addis Ababa e-Trade stores the numbered woreda + * here. Never consulted for an ordinary region/zone/woreda address. + */ + kebele?: string | null; } type Level = "country" | "region" | "zone" | "woreda"; @@ -115,6 +120,24 @@ const ALIASES: MorAlias[] = [ from: "Jigjiga", to: "JIJIGA", }, + // MoR misspells the region itself — PARISH_NO 11 is "AMAHARA". No other parish is close to it. + { level: "region", from: "Amhara", to: "AMAHARA" }, + // Addis Ababa sub-cities, where MoR's sheet and e-Trade disagree on spelling. Each confirmed by + // CITY_NO under PARISH_NO 13; the seven that already agree (ARADA, ADDIS KETEMA, LIDETA, KIRKOS, + // YEKA, BOLE, GULLELE) need no entry. LEMI KURA is deliberately absent — the Ministry sheet does + // not list the 2020 split at all, so it must keep failing rather than be mapped onto a neighbour. + { level: "zone", region: "ADDIS ABABA", from: "Kolfe Keraniyo", to: "KOLFIE KERANIYO" }, // 81 + { level: "zone", region: "ADDIS ABABA", from: "Kolfe Keranio", to: "KOLFIE KERANIYO" }, // 81 + { level: "zone", region: "ADDIS ABABA", from: "Nifas Silk Lafto", to: "NEFAS SILK LAFTO" }, // 80 + { level: "zone", region: "ADDIS ABABA", from: "Akaki Kality", to: "AKAKI KALITI" }, // 79 + // MoR keeps the Amharic compass words for the Oromia zones; e-Trade stores the English ones. + // Each pair confirmed by the zone's own localities in the sheet: MISRAK SHOA holds ADAMA and + // BISHOFTU, MIRAB SHOA holds AMBO and WELMERA, MIRAB HARARGE holds CHIRO and GEMMECHIS. + { level: "zone", region: "OROMIA", from: "East Shewa", to: "MISRAK SHOA" }, // 16 + { level: "zone", region: "OROMIA", from: "West Shewa", to: "MIRAB SHOA" }, // 62 + { level: "zone", region: "OROMIA", from: "West Hararge", to: "MIRAB HARARGE" }, // 7 + // MoR drops a J. Confirmed by BAHIRDAR ZURIA / MECHA / BURIE sitting under CITY_NO 53. + { level: "zone", region: "AMAHARA", from: "West Gojjam", to: "WEST GOJAM" }, // 53 ]; /** @@ -127,6 +150,20 @@ const ALIASES: MorAlias[] = [ const zoneSuffixCandidates = (normalized: string): string[] => normalized.endsWith(" ZONE") ? [] : [`${normalized} ZONE`]; +/** + * In the chartered cities MoR names each locality "WOREDA 7", while e-Trade stores the bare, + * zero-padded number ("07") and EDR's own forms sometimes store "Woreda 05". All three mean the + * same locality, so the MoR spelling is tried as a second exact-match candidate — MoR writes no + * leading zero, hence the strip. Applied to the locality level only. + * + * This runs ahead of the numeric LOCALITY_NO fallback below, and can never mask it: no city in the + * Ministry sheet contains both a "WOREDA n" locality and a locality whose LOCALITY_NO is n. + */ +const woredaNumberCandidates = (normalized: string): string[] => { + const match = /^(?:WOREDA )?0*([0-9]{1,2})$/.exec(normalized); + return match ? [`WOREDA ${match[1]}`] : []; +}; + export class MorGeoMappingError extends BadRequestException { constructor(code: "EIMS_GEO_MAPPING_FAILED" | "EIMS_GEO_AMBIGUOUS", message: string) { super({ code, message }); @@ -158,6 +195,7 @@ function matchLevel( if (normalizeName(alias.from) === wanted) candidates.push(normalizeName(alias.to)); } if (level === "zone") candidates.push(...zoneSuffixCandidates(wanted)); + if (level === "woreda") candidates.push(...woredaNumberCandidates(wanted)); } let matched: MorLocationTuple[] = []; @@ -221,25 +259,46 @@ export function resolveMorGeo( const inCountry = matchLevel(rows, "country", country, {}, input); const inRegion = matchLevel(inCountry.rows, "region", input.region, {}, input); const regionScope = normalizeName(inRegion.rows[0][SLOTS.region.name] as string); - const inZone = matchLevel(inRegion.rows, "zone", input.zone, { region: regionScope }, input); - const zoneScope = normalizeName(inZone.rows[0][SLOTS.zone.name] as string); - const inWoreda = matchLevel( - inZone.rows, - "woreda", - input.woreda, - { - region: regionScope, - zone: zoneScope, - }, - input, - ); - return { - Country: String(inCountry.no), - Region: String(inRegion.no), - City: String(inZone.no), - Wereda: String(inWoreda.no), + type Name = string | null | undefined; + const below = (zone: Name, woreda: Name): MorGeoCodes => { + const inZone = matchLevel(inRegion.rows, "zone", zone, { region: regionScope }, input); + const zoneScope = normalizeName(inZone.rows[0][SLOTS.zone.name] as string); + const inWoreda = matchLevel( + inZone.rows, + "woreda", + woreda, + { + region: regionScope, + zone: zoneScope, + }, + input, + ); + return { + Country: String(inCountry.no), + Region: String(inRegion.no), + City: String(inZone.no), + Wereda: String(inWoreda.no), + }; }; + + try { + return below(input.zone, input.woreda); + } catch (err) { + // Addis Ababa (and every other chartered city) has no zone tier: MoR's CITY level *is* the + // sub-city and its LOCALITY level is the numbered woreda. e-Trade fills the missing tier by + // repeating the region in `zone`, which pushes the sub-city into `woreda` and the woreda + // number into `kebele` — one level down the whole way. Retry with that reading, but only when + // `zone` genuinely repeats the region, and only accept it when *both* shifted levels resolve + // exactly. A zone MoR simply does not list still fails with its own message, unreinterpreted. + const zone = normalizeName(input.zone); + if (!zone || (zone !== regionScope && zone !== normalizeName(input.region))) throw err; + try { + return below(input.woreda, input.kebele); + } catch { + throw err; + } + } } /** Non-throwing variant for callers that already have a working fallback (the seller identity). */ diff --git a/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.ts index 875349b6a..e8c111b62 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.ts @@ -134,6 +134,7 @@ export class EimsBulkRegistrationService { region: invoice.company?.region, zone: invoice.company?.zone, woreda: invoice.company?.woreda, + kebele: invoice.company?.kebele, }); return { invoice, documentType, relatedDocument, buyerGeo }; }); diff --git a/apps/edr-freight-api/src/modules/eims/eims-client.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-client.service.spec.ts new file mode 100644 index 000000000..4bb20d80e --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-client.service.spec.ts @@ -0,0 +1,140 @@ +import { HttpService } from "@nestjs/axios"; +import { ConfigService } from "@nestjs/config"; +import { AxiosError, AxiosHeaders } from "axios"; +import { of, throwError } from "rxjs"; + +import { EimsConfig } from "../../config/eims.config"; +import { EimsAuthService } from "./eims-auth.service"; +import { EimsClientService } from "./eims-client.service"; +import { EimsSignerService } from "./eims-signer.service"; +import { eimsConfig } from "./eims-test-fixtures"; + +const API_KEY = "super-secret-apikey"; +const CLIENT_SECRET = "super-secret-value"; +const TOKEN = "access-token-value"; + +/** Stub signer: the real signing path has its own spec and needs no key material here. */ +const signer = { + signRequest: (request: T) => ({ request, signature: "SIGNATURE", certificate: "CERTIFICATE" }), +} as unknown as EimsSignerService; + +const build = (post: jest.Mock, config: EimsConfig = eimsConfig(), token: string = TOKEN) => + new EimsClientService( + { post } as unknown as HttpService, + { get: () => config } as unknown as ConfigService, + { + getValidAccessToken: jest.fn().mockResolvedValue(token), + invalidate: jest.fn(), + } as unknown as EimsAuthService, + signer, + ); + +const ok = (data: unknown = { statusCode: 200, body: { Irn: "irn-echoed" } }) => + jest.fn().mockReturnValue(of({ data })); + +const axiosErr = (status: number, data: unknown) => + new AxiosError("Request failed", undefined, undefined, undefined, { + status, + statusText: "", + data, + headers: new AxiosHeaders(), + config: { headers: new AxiosHeaders() }, + }); + +/** `post(url, body, config)` — the config argument every assertion below reads. */ +const sentConfig = (post: jest.Mock, call = 0) => post.mock.calls[call][2]; +const sentBody = (post: jest.Mock, call = 0) => post.mock.calls[call][1]; + +describe("EimsClientService transport", () => { + it("bearer-authenticates every protected call", async () => { + const post = ok(); + await build(post).postBearer("/v1/cancel", { Irn: "irn-1" }); + + expect(sentConfig(post).headers).toEqual({ + "Content-Type": "application/json", + Authorization: `Bearer ${TOKEN}`, + }); + }); + + it("sends the same headers on a signed call", async () => { + const post = ok({ statusCode: 200, body: { irn: "irn-1" } }); + await build(post).postSigned("/v1/register", { Invoice: 1 }); + + expect(sentConfig(post).headers).toEqual({ + "Content-Type": "application/json", + Authorization: `Bearer ${TOKEN}`, + }); + }); + + it("wraps a signed call in the {request,signature,certificate} envelope", async () => { + const post = ok({ statusCode: 200, body: { irn: "irn-1" } }); + await build(post).postSigned("/v1/register", { Invoice: 1 }); + + expect(JSON.parse(sentBody(post) as string)).toEqual({ + request: { Invoice: 1 }, + signature: "SIGNATURE", + certificate: "CERTIFICATE", + }); + }); + + it("leaves an unsigned body verbatim", async () => { + const post = ok(); + await build(post).postBearer("/v1/cancel", { Irn: "irn-1" }); + + // Raw object, not the JSON string `toSignedBody` produces. + expect(sentBody(post)).toEqual({ Irn: "irn-1" }); + }); + + it("re-signs and re-authenticates through the one 401 retry", async () => { + const post = jest + .fn() + .mockReturnValueOnce(throwError(() => axiosErr(401, { message: "expired" }))) + .mockReturnValueOnce(of({ data: { statusCode: 200, body: { Irn: "irn-1" } } })); + + await build(post).postSigned("/v1/verify", { irn: "irn-1" }); + + expect(post).toHaveBeenCalledTimes(2); + expect(sentConfig(post, 1).headers.Authorization).toBe(`Bearer ${TOKEN}`); + expect(JSON.parse(sentBody(post, 1) as string)).toMatchObject({ + request: { irn: "irn-1" }, + signature: "SIGNATURE", + }); + }); + + it("never leaks the api key, bearer token or client secret into a thrown failure", async () => { + const post = jest.fn().mockReturnValue( + throwError(() => + // The live shape of an unsigned /v1/verify rejection, as observed on 2026-09-01. + axiosErr(400, { + message: "GATEWAY ERROR", + code: "4001", + details: [ + { field: "certificate", errorMessage: "must not be null" }, + { field: "signature", errorMessage: "must not be null" }, + { field: "request", errorMessage: "must not be null" }, + ], + // An echoed request is exactly what redaction has to drop. + request: { apikey: API_KEY, clientSecret: CLIENT_SECRET }, + }), + ), + ); + + const error: Error = await build(post) + .postSigned("/v1/verify", { irn: "irn-1" }) + .then(() => { + throw new Error("expected the call to reject"); + }) + .catch((err: Error) => err); + + const serialized = JSON.stringify({ + message: error.message, + response: (error as { getResponse?: () => unknown }).getResponse?.(), + details: (error as { details?: unknown }).details, + }); + expect(serialized).not.toContain(API_KEY); + expect(serialized).not.toContain(CLIENT_SECRET); + expect(serialized).not.toContain(TOKEN); + // The gateway's own reporting still survives redaction. + expect(error.message).toContain("4001"); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-client.service.ts b/apps/edr-freight-api/src/modules/eims/eims-client.service.ts index 610647b1a..1870814c5 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-client.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-client.service.ts @@ -11,7 +11,7 @@ import { toEimsApiException } from "./eims.errors"; * Foundation for EIMS's bearer-authenticated endpoints (`/v1/register`, `/v1/verify`, …). * * Login is not routed through here: `/auth/login` carries no bearer token and lives in - * `EimsAuthService`. Nothing calls `postSigned` yet — invoice registration is a later phase. + * `EimsAuthService`. */ @Injectable() export class EimsClientService { @@ -39,10 +39,15 @@ export class EimsClientService { /** * POST `request` verbatim — bearer-authenticated but **not** wrapped in a signed envelope. * - * `/v1/verify` is the only endpoint observed to work this way: the supplied collection sends a - * raw `{"irn":"…"}` body with no `signature`/`certificate` siblings. Kept as its own entry point - * so that if the live gateway turns out to require signing after all, exactly one call site - * changes — `postSigned` is already the alternative. + * The supplied collection sends raw bodies for `/v1/verify`, `/v1/cancel` and the receipt + * endpoints. That turned out to be wrong for `/v1/verify`, which the live gateway rejects with + * `GATEWAY ERROR code=4001` (`request`/`signature`/`certificate` must not be null) until the + * envelope is added — so verify now uses `postSigned`. + * + * The remaining callers (cancel, bulk cancel, receipts) still send raw bodies and have **not** + * been exercised against the live gateway. Each is a candidate for the same rejection; none can + * be probed safely, because unlike verify they all mutate state at MoR. Expect to convert them + * the same way the first time one is filed for real. */ async postBearer(path: string, request: TRequest): Promise { return this.send(path, request, false, false); 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 69a4d5ffd..8f8a94a7c 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 @@ -759,38 +759,60 @@ describe("EimsInvoiceRegistrationService staff alerting", () => { }); describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => { - it("verifies the stored IRN over the unsigned bearer transport", async () => { + it("verifies the stored IRN over the signed transport", async () => { const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]); - const postSigned = jest.fn(); - const postBearer = jest.fn().mockResolvedValue(verifyResponse()); + const postBearer = jest.fn(); + const postSigned = jest.fn().mockResolvedValue(verifyResponse()); const result = await build(db, postSigned, config(), postBearer).verifyInvoiceWithEims( INVOICE_ID, ); - // Lowercase `irn`, raw body — not a signed envelope. `postSigned` must stay untouched. - expect(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN }); - expect(postSigned).not.toHaveBeenCalled(); + // Lowercase `irn`, signed envelope — the live gateway rejects the unsigned body with + // `code=4001` naming request/signature/certificate as null. + expect(postSigned).toHaveBeenCalledWith("/v1/verify", { irn: IRN }); + expect(postBearer).not.toHaveBeenCalled(); expect(result.body).toMatchObject({ Irn: IRN }); }); it("rejects a 200 that carries no Irn", async () => { const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]); - const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: { Irn: " " } }); + const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { Irn: " " } }); await expect( - build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID), + build(db, postSigned, config(), jest.fn()).verifyInvoiceWithEims(INVOICE_ID), ).rejects.toThrow(/returned no Irn/); }); it("refuses to verify an invoice with no IRN", async () => { const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Unknown })]); - const postBearer = jest.fn(); + const postSigned = jest.fn(); await expect( - build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID), + build(db, postSigned, config(), jest.fn()).verifyInvoiceWithEims(INVOICE_ID), ).rejects.toThrow(/no EIMS IRN to verify/); - expect(postBearer).not.toHaveBeenCalled(); + expect(postSigned).not.toHaveBeenCalled(); + }); + + it("leaves a filed invoice and the IRN chain untouched when the gateway rejects the verify", async () => { + const db = new FakeDb([ + invoiceRow({ eimsIrn: IRN, eimsStatus: EimsInvoiceStatus.Registered }), + ]); + const before = { ...db.invoices.get(INVOICE_ID)! }; + const stateBefore = { ...db.state! }; + // The live failure this guards: `GATEWAY ERROR code=4001`, a transport fault on a document + // that is already registered. Verification is a read — a failed read must never downgrade the + // registration or move the counter. + const postSigned = jest + .fn() + .mockRejectedValue(new EimsApiException("SCHEMA_VALIDATION", "GATEWAY ERROR code=4001", 400)); + + await expect( + build(db, postSigned, config(), jest.fn()).verifyInvoiceWithEims(INVOICE_ID), + ).rejects.toThrow(/4001/); + + expect(db.invoices.get(INVOICE_ID)).toEqual(before); + expect(db.state).toEqual(stateBefore); }); }); @@ -813,15 +835,15 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { it("records a confirmed IRN, resumes the chain and clears the block", async () => { const db = blocked(); - const postBearer = jest.fn().mockResolvedValue(verifyResponse()); + const postSigned = jest.fn().mockResolvedValue(verifyResponse()); - const view = await build(db, jest.fn(), config(), postBearer).resolveEimsRegistration( + const view = await build(db, postSigned, config(), jest.fn()).resolveEimsRegistration( INVOICE_ID, { irn: IRN }, ); // The IRN is confirmed at the gateway before it is ever written. - expect(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN }); + expect(postSigned).toHaveBeenCalledWith("/v1/verify", { irn: IRN }); expect(view).toMatchObject({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: IRN }); expect(db.state).toMatchObject({ previousIrn: IRN, @@ -832,12 +854,12 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { it("refuses an IRN the gateway answers with a different one, leaving the block intact", async () => { const db = blocked(); - const postBearer = jest + const postSigned = jest .fn() .mockResolvedValue(verifyResponse({ Irn: "0000000000000000000000000000000000000000" })); await expect( - build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), + build(db, postSigned, config(), jest.fn()).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), ).rejects.toThrow(/answered the lookup for IRN/); expect(db.invoices.get(INVOICE_ID)).toMatchObject({ @@ -853,14 +875,14 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { it("refuses an IRN whose document number is not this invoice, leaving the block intact", async () => { const db = blocked(); - const postBearer = jest.fn().mockResolvedValue( + const postSigned = jest.fn().mockResolvedValue( verifyResponse({ DocumentDetails: { Type: "INV", DocumentNumber: "99999" }, }), ); await expect( - build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), + build(db, postSigned, config(), jest.fn()).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), ).rejects.toThrow(/not 5/); expect(db.invoices.get(INVOICE_ID)).toMatchObject({ @@ -876,25 +898,25 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { it("refuses an IRN the gateway does not acknowledge at all", async () => { const db = blocked(); - const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: {} }); + const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: {} }); await expect( - build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), + build(db, postSigned, config(), jest.fn()).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), ).rejects.toThrow(/returned no Irn/); expect(db.state!.blockedReason).toBe("never acknowledged"); }); it("discards the attempt, leaving the chain where it was", async () => { const db = blocked(); - const postBearer = jest.fn(); + const postSigned = jest.fn(); - const view = await build(db, jest.fn(), config(), postBearer).resolveEimsRegistration( + const view = await build(db, postSigned, config(), jest.fn()).resolveEimsRegistration( INVOICE_ID, { discard: true }, ); expect(view).toMatchObject({ eimsStatus: EimsInvoiceStatus.Failed, eimsIrn: null }); - expect(postBearer).not.toHaveBeenCalled(); // nothing to confirm + expect(postSigned).not.toHaveBeenCalled(); // nothing to confirm expect(db.state).toMatchObject({ previousIrn: null, inFlightInvoiceId: null, @@ -908,10 +930,10 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { OTHER_INVOICE_ID, invoiceRow({ id: OTHER_INVOICE_ID, eimsDocumentNumber: "6" }), ); - const postBearer = jest.fn().mockResolvedValue(verifyResponse()); + const postSigned = jest.fn().mockResolvedValue(verifyResponse()); await expect( - build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(OTHER_INVOICE_ID, { + build(db, postSigned, config(), jest.fn()).resolveEimsRegistration(OTHER_INVOICE_ID, { irn: IRN, }), ).rejects.toThrow(/in-flight EIMS submission is invoice/); diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts index d5c987779..65221d48c 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts @@ -126,6 +126,7 @@ export class EimsInvoiceRegistrationService { region: invoice.company?.region, zone: invoice.company?.zone, woreda: invoice.company?.woreda, + kebele: invoice.company?.kebele, }); // Authenticate before reserving: the source system comes from the token, and the state row is @@ -213,10 +214,13 @@ export class EimsInvoiceRegistrationService { * compared — the supplied collection's own fixture uses different example values on each side, * so equality there would assert a property of the mock rather than of the gateway. * - * Bearer-authenticated but unsigned, via `postBearer` — see that method for why. + * Signed, via `postSigned`. The supplied collection shows a raw `{"irn":"…"}` body, but the live + * gateway rejects that with `GATEWAY ERROR code=4001` naming `request`, `signature` and + * `certificate` as null — verified against `core.mor.gov.et` on 2026-09-01. The signed envelope + * clears that validation. The collection's unsigned example is wrong for this endpoint. */ private async queryVerify(irn: string): Promise { - const response = await this.client.postBearer( + const response = await this.client.postSigned( "/v1/verify", { irn }, ); diff --git a/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts index 7afe644a1..ca5cb6a30 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts @@ -116,6 +116,7 @@ export class EimsSellerCacheService implements OnModuleInit { region: data.region, zone: data.zone, woreda: data.woreda, + kebele: data.kebele, }); this.cached = { // The *legal* entity name, not the licence's trade name that diff --git a/apps/edr-freight-api/src/modules/warehouses/train-loading-window-gate.spec.ts b/apps/edr-freight-api/src/modules/warehouses/train-loading-window-gate.spec.ts new file mode 100644 index 000000000..fe9adb389 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/train-loading-window-gate.spec.ts @@ -0,0 +1,77 @@ +import { WarehouseInventoryService } from './warehouse-inventory.service'; +import type { TrainLoadableItemRow } from './warehouse-inventory.service'; + +/** + * Cargo may only go onto a wagon inside a STARTED loading window at its + * boarding yard — the same rule the train schedule's own Load button enforces + * (assertStationWorkStarted). The warehouse loading queues load through a + * different service, so the rule is mirrored here; without it the two surfaces + * disagree and the queue offers a Load the schedule would refuse. + * + * Only the DataSource is touched, so the instance is built off the prototype + * rather than stubbing every collaborator. + */ +const row = (over: Partial = {}): TrainLoadableItemRow => + ({ + id: 'inv-1', + bookingId: 'b-1', + bookingReference: 'BK-1', + customerName: 'Acme', + containerNumber: 'CN-1', + cargoType: 'General', + weight: 20, + grnNumber: 'GRN-1', + inspectionStatus: 'PASSED', + status: 'READY_FOR_LOADING', + wagonId: 'w-1', + wagonNumber: 'W-001', + sequenceNo: 1, + originYardId: 'yard-1', + originYardLabel: 'Modjo', + loadingWindowStarted: true, + loadable: true, + ...over, + }) as TrainLoadableItemRow; + +function makeService(items: TrainLoadableItemRow[]) { + const query = jest.fn().mockResolvedValue([ + { trainNumber: 'T-100', origin: 'Modjo', destination: 'Djibouti', departure: null }, + ]); + const load = jest.fn().mockResolvedValue(undefined); + const service = Object.create(WarehouseInventoryService.prototype) as Record; + service.dataSource = { query }; + service.load = load; + service.trainLoadableItems = jest.fn().mockResolvedValue(items); + return { service: service as unknown as WarehouseInventoryService, load }; +} + +describe('loadItemsOntoTrain() — station loading window gate', () => { + it('skips an item whose boarding yard has no started loading window', async () => { + const { service, load } = makeService([row({ loadingWindowStarted: false })]); + + const result = await service.loadItemsOntoTrain('sched-1', ['inv-1']); + + expect(load).not.toHaveBeenCalled(); + expect(result.loadedCount).toBe(0); + expect(result.skippedCount).toBe(1); + expect(result.results[0].reason).toContain('Start loading at Modjo first'); + }); + + it('loads once the window is started', async () => { + const { service, load } = makeService([row()]); + + const result = await service.loadItemsOntoTrain('sched-1', ['inv-1']); + + expect(load).toHaveBeenCalledTimes(1); + expect(result.loadedCount).toBe(1); + expect(result.skippedCount).toBe(0); + }); + + it('still reports the wagon blocker first — the window is not the only gate', async () => { + const { service } = makeService([row({ wagonId: null, loadingWindowStarted: false })]); + + const result = await service.loadItemsOntoTrain('sched-1', ['inv-1']); + + expect(result.results[0].reason).toContain('No wagon allocated'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 653c2ea5a..5956f0bcf 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -19,6 +19,7 @@ import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { generateGrnNumber } from '../../common/grn.util'; import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql'; import { Booking } from '../bookings/entities/booking.entity'; +import type { StationWorkLog } from '../train-schedules/entities/train-schedule.entity'; import { Cargo } from '../cargoes/entities/cargoes.entity'; import { Company } from '../companies/entities/company.entity'; import { Container } from '../container-management/entities/container.entity'; @@ -326,7 +327,7 @@ export interface LoadableTrainRow { * schedule page stores them. The warehouse loading queues render the same * Start/End controls off this, so both surfaces show one truth. */ - stationWorkLogs: Record | null; + stationWorkLogs: Record | null; /** Received/ready inventory not yet loaded onto this train. */ readyCount: number; /** Inventory already loaded onto this train. */ @@ -1855,6 +1856,8 @@ export class WarehouseInventoryService { dy.country AS "destinationCountry", ts.status AS "status", ts.scheduled_departure_date AS "departureTime", + ts.origin_station_id AS "originStationId", + ts.station_work_logs AS "stationWorkLogs", (SELECT count(*) FROM sched_bookings sb JOIN freight.warehouse_inventory inv ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL @@ -1918,7 +1921,15 @@ export class WarehouseInventoryService { inv.status AS "status", wl.wagon_id AS "wagonId", wl.wagon_number AS "wagonNumber", - wl.sequence_no AS "sequenceNo" + wl.sequence_no AS "sequenceNo", + COALESCE(b.origin_yard_id, ts.origin_station_id) AS "originYardId", + COALESCE(oy.label, oy.code) AS "originYardLabel", + -- Same rule the train schedule's own Load button obeys + -- (assertStationWorkStarted): the yard's loading window must have + -- been started before its cargo may go on a wagon. + (ts.station_work_logs #>> ARRAY[ + COALESCE(b.origin_yard_id, ts.origin_station_id)::text, 'loading', 'startedAt' + ]) IS NOT NULL AS "loadingWindowStarted" FROM sched_bookings sb JOIN freight.train_schedules ts ON ts.id = sb.schedule_id JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL @@ -1926,6 +1937,7 @@ export class WarehouseInventoryService { LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id LEFT JOIN freight.containers ct ON ct.id = inv.container_id + LEFT JOIN freight.yards oy ON oy.id = COALESCE(b.origin_yard_id, ts.origin_station_id) LEFT JOIN LATERAL ( SELECT w.id AS wagon_id, w.wagon_number, tsw.sequence_no FROM freight.wagon_booking_allocations wba @@ -1948,9 +1960,14 @@ export class WarehouseInventoryService { ...r, // Export flow: received at the warehouse -> GRN -> loaded onto its wagon. // The row only exists once the goods were received, so requiring a GRN and - // an allocated wagon completes the chain. + // an allocated wagon completes the chain. The yard's loading window is the + // fourth link — the warehouse queue must not offer what the train + // schedule's own Load button would refuse. loadable: - r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId) && Boolean(r.grnNumber), + r.status === 'READY_FOR_LOADING' && + Boolean(r.wagonId) && + Boolean(r.grnNumber) && + r.loadingWindowStarted, })); } @@ -2007,6 +2024,11 @@ export class WarehouseInventoryService { // nothing rides a train without one. if (!item.grnNumber) { skip('No GRN — receive the goods and generate the GRN first'); continue; } if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; } + // Mirrors assertStationWorkStarted on the train-schedule load path. + if (!item.loadingWindowStarted) { + skip(`Start loading at ${item.originYardLabel ?? 'the boarding yard'} first — the loading time window has not been started`); + continue; + } try { await this.load(inventoryId, { diff --git a/apps/edr-freight-api/src/scripts/seed-mor-test-buyers.ts b/apps/edr-freight-api/src/scripts/seed-mor-test-buyers.ts new file mode 100644 index 000000000..25ae2f038 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-mor-test-buyers.ts @@ -0,0 +1,100 @@ +import { AppDataSource } from "../data-source"; + +/** + * Seeds the 30 test taxpayers the Ministry of Revenues issued for the EIMS/BSP + * **non-self buyer** certification run — the checklist item that needs a real + * invoice filed against a buyer that is not EDR itself. + * + * Every field comes from MoR's own roster, except the two location codes, which + * do not survive contact with the Ministry's own location master: + * + * - MoR's sheet gives `Region = "1"`. `PARISH_NO 1` is not an Ethiopian region + * at all (it is Djibouti), so the region is stored by name — `Bole` is an + * Addis Ababa sub-city, which fixes the region unambiguously (PARISH_NO 13). + * - MoR's sheet gives `City = "101"`, which is GOFA ZONE in SNNPRS. The buyer + * is in Bole, so the sub-city is stored by name (CITY_NO 78). + * + * In other words MoR does not validate the geographic codes it sends itself; + * these rows carry the addresses that actually resolve. The roster carries no + * woreda, so every row takes `NO WOREDA-144` — MoR's *own* "not specified" + * locality under BOLE (LOCALITY_NO 574), the same code EDR's static seller + * details already file under. Nothing here is invented. + * + * Idempotent: `ON CONFLICT (tin) DO NOTHING`. TIN 0089238373 is already on file + * as a real customer (Afri Software Solutions) and is deliberately left alone. + */ + +/** + * `[TIN, phone, legal name, email, kebele?, house number?]`, verbatim from MoR's roster. The two + * trailing fields default to the values 22 of the 30 rows share. + */ +const KEBELE = "Near Bole Airport"; +const HOUSE_NO = "123B"; + +const MOR_TEST_BUYERS: Array<[string, string, string, string, string?, string?]> = [ + ["0089238373", "251911091245", "Taxpayer A", "codethicaet@gmail.com", "Near Airport", "101"], + ["0054864576", "251911091245", "Taxpayer B", "shehir8@gmail.com", "Near Airport"], + ["0049056594", "251911091245", "Taxpayer C", "teme@odooethiopia.com", "Near Airport"], + ["0059819904", "251911091245", "Taxpayer D", "rubiethoplc@gmail.com", "Near Airport"], + ["0000018932", "251911091245", "Taxpayer E", "amanuelephremedu@gmail.com", "Near Airport"], + ["0088683375", "251911091245", "Taxpayer F", "ermiastegegn576@gmail.com", "Near Airport"], + ["0068421445", "251911091245", "Taxpayer G", "qelemmeda@gmail.com", "Near Airport"], + ["0004404844", "251911091245", "Taxpayer H", "dagnegamu24@gmail.com"], + ["0000037187", "251911091245", "Taxpayer I", "deresr.belay@gmail.com"], + ["0050167460", "251911091245", "Taxpayer J", "sera2013ec@gmail.com"], + ["0079690836", "251909978781", "Taxpayer K", "asmeradefa@gmail.com"], + ["0068180813", "251944310004", "Taxpayer L", "hailelt@gmail.com"], + ["0083907363", "251944310004", "Taxpayer M", "dawitfissha1@gmail.com"], + ["0089032785", "251944310004", "Taxpayer N", "tewahido11@gmail.com"], + ["0003826418", "251944310004", "Taxpayer O", "alemayehu.t@marakisoft.com"], + ["0053374665", "251944310004", "Taxpayer P", "getlelaw@gmail.com"], + ["0016175194", "251911463482", "Taxpayer Q", "abiye.abi@gmail.com", "Near Airport"], + ["0094542975", "251911463482", "Taxpayer R", "abelgebreananya@gmail.com"], + // MoR's roster carries an 11-digit phone here; kept verbatim rather than "corrected". + ["0088514835", "25191124368", "Taxpayer S", "ewnget77@gmail.com"], + ["0076217301", "251960403750", "Taxpayer T", "merontamirat.redcloud@gmail.com"], + ["0003826419", "251911516507", "Taxpayer 322", "alemayehu.t@marakisoft.com"], + ["0056961577", "251929020729", "Taxpayer 323", "ltictsolution@gmail.com", undefined, "1234B"], + ["0090853345", "251911376145", "Taxpayer 324", "kidusgoshu2be@gmail.com"], + ["0000028643", "251988899003", "Taxpayer 325", "mesaysisay10@gmail.com"], + ["0057751727", "251911437928", "Taxpayer 326", "zewdugeta@gmail.com"], + ["0082549522", "251907256543", "Taxpayer 327", "brookgm2@gmail.com"], + ["0093283311", "251953915419", "Taxpayer 328", "henock.ad@gmail.com"], + ["0078795374", "251911091245", "Taxpayer 329", "danielltadesse@gmail.com"], + ["0093346931", "251935724920", "Taxpayer 330", "halidabd63@gmail.com"], + ["0040887091", "251913792959", "Taxpayer 331", "milextech@gmail.com"], +]; + +async function seedMorTestBuyers(): Promise { + await AppDataSource.initialize(); + try { + for (const [tin, phone, name, email, kebele, houseNo] of MOR_TEST_BUYERS) { + await AppDataSource.query( + `INSERT INTO freight.companies + (name, type, kind, status, tin, country, region, zone, woreda, kebele, + house_no, phone, email) + VALUES ($1, 'customer', 'commercial', 'active', $2, 'Ethiopia', 'Addis Ababa', 'Bole', + 'NO WOREDA-144', $3, $4, $5, $6) + ON CONFLICT (tin) DO NOTHING`, + [name, tin, kebele ?? KEBELE, houseNo ?? HOUSE_NO, phone, email], + ); + } + + const summary = await AppDataSource.query( + `SELECT count(*)::int AS on_file, + count(*) FILTER (WHERE name LIKE 'Taxpayer %')::int AS seeded + FROM freight.companies + WHERE tin = ANY($1::text[])`, + [MOR_TEST_BUYERS.map(([tin]) => tin)], + ); + console.table(summary); + console.log("Seeded MoR EIMS/BSP test buyers."); + } finally { + await AppDataSource.destroy(); + } +} + +seedMorTestBuyers().catch((err) => { + console.error("MoR test buyer seed failed:", err); + process.exit(1); +}); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/LoadToTrainPanel.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/LoadToTrainPanel.tsx index 828f4ab44..6b0e0f1b2 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/LoadToTrainPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/LoadToTrainPanel.tsx @@ -22,6 +22,7 @@ import { type TrainLoadableItem, } from '@/services/warehouse.service'; import { extractErrorMessage } from './options'; +import { YardLoadingWindows } from './YardLoadingWindows'; const STAGE_COLOR: Record = { RECEIVED: 'blue', @@ -161,6 +162,11 @@ function TrainRow({ train, expanded, onToggle }: { train: LoadableTrain; expande ) : ( + {bookings.map((b) => ( ))} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index f87d6d24d..ba75f0c35 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -90,6 +90,7 @@ import { MoveInventoryModal } from './MoveInventoryModal'; import { ReleaseOrderModal } from './ReleaseOrderModal'; import { StoreInventoryModal } from './StoreInventoryModal'; import { WarehouseInquiryTable } from './WarehouseInquiryTable'; +import { YardLoadingWindows } from './YardLoadingWindows'; import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions, warehousesAtStation, yardsForBooking } from './options'; import { openPdfBlob } from './pdf'; import ListControls from '@/components/common/ListControls'; @@ -1598,6 +1599,11 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: queryFn: () => warehouseService.getLoadableTrains(), enabled: enabled && trainPickerOpen, }); + const { data: pickerItems = [] } = useQuery({ + queryKey: ['train-loadable-items', targetScheduleId], + queryFn: () => warehouseService.getTrainLoadableItems(targetScheduleId!), + enabled: Boolean(targetScheduleId) && trainPickerOpen, + }); const loadOntoTrain = useMutation({ mutationFn: async ({ scheduleId, onlyIds }: { scheduleId: string; onlyIds: string[] }) => { const items = await warehouseService.getTrainLoadableItems(scheduleId); @@ -1608,6 +1614,17 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: loadableIds = loadableIds.filter((id) => picked.has(id)); } if (!loadableIds.length) { + const scope = onlyIds.length + ? items.filter((i) => onlyIds.includes(i.id)) + : items.filter((i) => i.status === 'READY_FOR_LOADING'); + // A closed loading window is the blocker staff hit most, and the old + // wagon-only message sent them to fix the wrong thing. + const shut = scope.find((i) => !i.loadingWindowStarted); + if (shut) { + throw new Error( + `Start loading at ${shut.originYardLabel ?? 'the boarding yard'} first — the loading time window has not been started`, + ); + } throw new Error( onlyIds.length ? 'None of the selected items have an allocated wagon on this train' @@ -1701,6 +1718,13 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: searchable /> )} + {targetScheduleId ? ( + t.scheduleId === targetScheduleId)?.stationWorkLogs} + /> + ) : null}