mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 07:23:40 +00:00
Merge pull request #1469 from Tria-plc/Wrehousechanges
Mor Verify fix fix(eims): sign the /v1/verify request body
This commit is contained in:
@@ -32,6 +32,7 @@
|
||||
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
|
||||
"seed:dropdown-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-dropdown-settings.ts",
|
||||
"seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts",
|
||||
"seed:mor-test-buyers": "ts-node -r tsconfig-paths/register src/scripts/seed-mor-test-buyers.ts",
|
||||
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh",
|
||||
"iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js",
|
||||
"iam:migration:run": "pnpm run iam:typeorm:cli migration:run",
|
||||
|
||||
@@ -29,6 +29,8 @@ const FIXTURE: MorLocationTuple[] = [
|
||||
[70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 976, "Wal-Mera"],
|
||||
[70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 909, "Akaki woreda"],
|
||||
[70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1100, "WOREDA 1"],
|
||||
[70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1102, "WOREDA 3"],
|
||||
[70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1139, "WOREDA 7"],
|
||||
[253, "Djibouti", 1, "DJIBOUTI", 1, "DJIBOUTI VILLE", 1, "BALBALA"],
|
||||
];
|
||||
|
||||
@@ -181,6 +183,93 @@ describe("resolveMorGeo", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Addis Ababa, where MoR has no zone tier", () => {
|
||||
// e-Trade's real shape for a chartered city: `zone` repeats the region, the sub-city sits in
|
||||
// `woreda`, and the numbered woreda sits in `kebele`. This is how every company imported from
|
||||
// e-Trade stores an Addis Ababa address, and it is the shape that blocked INV-20260829-00011.
|
||||
const ETRADE_SHAPE = {
|
||||
country: "Ethiopia",
|
||||
region: "Addis Ababa",
|
||||
zone: "Addis Ababa",
|
||||
woreda: "Kolfe-Keraniyo",
|
||||
kebele: "07",
|
||||
};
|
||||
|
||||
it("reads the sub-city and woreda one level down when the zone repeats the region", () => {
|
||||
expect(resolveMorGeo(ETRADE_SHAPE, FIXTURE)).toEqual({
|
||||
Country: "70",
|
||||
Region: "13",
|
||||
City: "81",
|
||||
Wereda: "1139",
|
||||
});
|
||||
});
|
||||
|
||||
it("matches MoR's own 'KOLFIE KERANIYO' spelling of the sub-city", () => {
|
||||
expect(resolveMorGeo({ ...ETRADE_SHAPE, woreda: "Kolfe Keranio" }, FIXTURE).City).toBe("81");
|
||||
});
|
||||
|
||||
it("reads a zero-padded number as MoR's 'WOREDA n' locality, in either slot", () => {
|
||||
const bole = { country: "Ethiopia", region: "Addis Ababa", zone: "Bole" };
|
||||
expect(resolveMorGeo({ ...bole, woreda: "03" }, FIXTURE).Wereda).toBe("1102");
|
||||
expect(resolveMorGeo({ ...bole, woreda: "Woreda 03" }, FIXTURE).Wereda).toBe("1102");
|
||||
expect(resolveMorGeo({ ...bole, woreda: "WOREDA 3" }, FIXTURE).Wereda).toBe("1102");
|
||||
});
|
||||
|
||||
it("still resolves the already-correct shape without shifting", () => {
|
||||
expect(
|
||||
resolveMorGeo(
|
||||
{ country: "Ethiopia", region: "ADDIS ABABA", zone: "BOLE", woreda: "WOREDA 1" },
|
||||
FIXTURE,
|
||||
),
|
||||
).toEqual({ Country: "70", Region: "13", City: "78", Wereda: "1100" });
|
||||
});
|
||||
|
||||
it("reports the zone failure, not the shifted one, when the shift does not resolve", () => {
|
||||
// LEMI KURA is a 2020 sub-city the Ministry sheet does not list. The shift must not turn
|
||||
// that into a confusing locality error, and must never land on a neighbouring sub-city.
|
||||
expect(() =>
|
||||
resolveMorGeo({ ...ETRADE_SHAPE, woreda: "Lemi Kura", kebele: "02" }, FIXTURE),
|
||||
).toThrow(/no MoR CITY_NAME match for country="Ethiopia", region="Addis Ababa"/);
|
||||
});
|
||||
|
||||
it("does not shift when the zone is simply an unknown zone", () => {
|
||||
expect(() =>
|
||||
resolveMorGeo(
|
||||
{
|
||||
country: "Ethiopia",
|
||||
region: "OROMIA",
|
||||
zone: "East Zone",
|
||||
woreda: "KERSA",
|
||||
kebele: "01",
|
||||
},
|
||||
FIXTURE,
|
||||
),
|
||||
).toThrow(/no MoR CITY_NAME match/);
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves the regions and zones MoR spells differently from e-Trade", () => {
|
||||
// Guards the reviewed alias table: MoR's PARISH_NAME is "AMAHARA", and it keeps the Amharic
|
||||
// compass words for the Oromia zones ("MISRAK SHOA" for East Shewa).
|
||||
const rows: MorLocationTuple[] = [
|
||||
...FIXTURE,
|
||||
[70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 21, "ADAMA"],
|
||||
[70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 149, "MECHA"],
|
||||
];
|
||||
expect(
|
||||
resolveMorGeo(
|
||||
{ country: "Ethiopia", region: "Oromia", zone: "East Shewa", woreda: "Adama" },
|
||||
rows,
|
||||
),
|
||||
).toEqual({ Country: "70", Region: "2", City: "16", Wereda: "21" });
|
||||
expect(
|
||||
resolveMorGeo(
|
||||
{ country: "Ethiopia", region: "Amhara", zone: "West Gojjam", woreda: "Mecha" },
|
||||
rows,
|
||||
),
|
||||
).toEqual({ Country: "70", Region: "11", City: "53", Wereda: "149" });
|
||||
});
|
||||
|
||||
describe("failures happen locally, before anything is filed", () => {
|
||||
const cases: Array<[string, Record<string, string>, RegExp]> = [
|
||||
["unknown country", { ...JIJIGA, country: "Wakanda" }, /no MoR COUNTRY_NAME match/],
|
||||
|
||||
@@ -43,6 +43,11 @@ export interface MorAddressInput {
|
||||
region?: string | null;
|
||||
zone?: string | null;
|
||||
woreda?: string | null;
|
||||
/**
|
||||
* Only read for the city-region shift below — in Addis Ababa e-Trade stores the numbered woreda
|
||||
* here. Never consulted for an ordinary region/zone/woreda address.
|
||||
*/
|
||||
kebele?: string | null;
|
||||
}
|
||||
|
||||
type Level = "country" | "region" | "zone" | "woreda";
|
||||
@@ -115,6 +120,24 @@ const ALIASES: MorAlias[] = [
|
||||
from: "Jigjiga",
|
||||
to: "JIJIGA",
|
||||
},
|
||||
// MoR misspells the region itself — PARISH_NO 11 is "AMAHARA". No other parish is close to it.
|
||||
{ level: "region", from: "Amhara", to: "AMAHARA" },
|
||||
// Addis Ababa sub-cities, where MoR's sheet and e-Trade disagree on spelling. Each confirmed by
|
||||
// CITY_NO under PARISH_NO 13; the seven that already agree (ARADA, ADDIS KETEMA, LIDETA, KIRKOS,
|
||||
// YEKA, BOLE, GULLELE) need no entry. LEMI KURA is deliberately absent — the Ministry sheet does
|
||||
// not list the 2020 split at all, so it must keep failing rather than be mapped onto a neighbour.
|
||||
{ level: "zone", region: "ADDIS ABABA", from: "Kolfe Keraniyo", to: "KOLFIE KERANIYO" }, // 81
|
||||
{ level: "zone", region: "ADDIS ABABA", from: "Kolfe Keranio", to: "KOLFIE KERANIYO" }, // 81
|
||||
{ level: "zone", region: "ADDIS ABABA", from: "Nifas Silk Lafto", to: "NEFAS SILK LAFTO" }, // 80
|
||||
{ level: "zone", region: "ADDIS ABABA", from: "Akaki Kality", to: "AKAKI KALITI" }, // 79
|
||||
// MoR keeps the Amharic compass words for the Oromia zones; e-Trade stores the English ones.
|
||||
// Each pair confirmed by the zone's own localities in the sheet: MISRAK SHOA holds ADAMA and
|
||||
// BISHOFTU, MIRAB SHOA holds AMBO and WELMERA, MIRAB HARARGE holds CHIRO and GEMMECHIS.
|
||||
{ level: "zone", region: "OROMIA", from: "East Shewa", to: "MISRAK SHOA" }, // 16
|
||||
{ level: "zone", region: "OROMIA", from: "West Shewa", to: "MIRAB SHOA" }, // 62
|
||||
{ level: "zone", region: "OROMIA", from: "West Hararge", to: "MIRAB HARARGE" }, // 7
|
||||
// MoR drops a J. Confirmed by BAHIRDAR ZURIA / MECHA / BURIE sitting under CITY_NO 53.
|
||||
{ level: "zone", region: "AMAHARA", from: "West Gojjam", to: "WEST GOJAM" }, // 53
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -127,6 +150,20 @@ const ALIASES: MorAlias[] = [
|
||||
const zoneSuffixCandidates = (normalized: string): string[] =>
|
||||
normalized.endsWith(" ZONE") ? [] : [`${normalized} ZONE`];
|
||||
|
||||
/**
|
||||
* In the chartered cities MoR names each locality "WOREDA 7", while e-Trade stores the bare,
|
||||
* zero-padded number ("07") and EDR's own forms sometimes store "Woreda 05". All three mean the
|
||||
* same locality, so the MoR spelling is tried as a second exact-match candidate — MoR writes no
|
||||
* leading zero, hence the strip. Applied to the locality level only.
|
||||
*
|
||||
* This runs ahead of the numeric LOCALITY_NO fallback below, and can never mask it: no city in the
|
||||
* Ministry sheet contains both a "WOREDA n" locality and a locality whose LOCALITY_NO is n.
|
||||
*/
|
||||
const woredaNumberCandidates = (normalized: string): string[] => {
|
||||
const match = /^(?:WOREDA )?0*([0-9]{1,2})$/.exec(normalized);
|
||||
return match ? [`WOREDA ${match[1]}`] : [];
|
||||
};
|
||||
|
||||
export class MorGeoMappingError extends BadRequestException {
|
||||
constructor(code: "EIMS_GEO_MAPPING_FAILED" | "EIMS_GEO_AMBIGUOUS", message: string) {
|
||||
super({ code, message });
|
||||
@@ -158,6 +195,7 @@ function matchLevel(
|
||||
if (normalizeName(alias.from) === wanted) candidates.push(normalizeName(alias.to));
|
||||
}
|
||||
if (level === "zone") candidates.push(...zoneSuffixCandidates(wanted));
|
||||
if (level === "woreda") candidates.push(...woredaNumberCandidates(wanted));
|
||||
}
|
||||
|
||||
let matched: MorLocationTuple[] = [];
|
||||
@@ -221,25 +259,46 @@ export function resolveMorGeo(
|
||||
const inCountry = matchLevel(rows, "country", country, {}, input);
|
||||
const inRegion = matchLevel(inCountry.rows, "region", input.region, {}, input);
|
||||
const regionScope = normalizeName(inRegion.rows[0][SLOTS.region.name] as string);
|
||||
const inZone = matchLevel(inRegion.rows, "zone", input.zone, { region: regionScope }, input);
|
||||
const zoneScope = normalizeName(inZone.rows[0][SLOTS.zone.name] as string);
|
||||
const inWoreda = matchLevel(
|
||||
inZone.rows,
|
||||
"woreda",
|
||||
input.woreda,
|
||||
{
|
||||
region: regionScope,
|
||||
zone: zoneScope,
|
||||
},
|
||||
input,
|
||||
);
|
||||
|
||||
return {
|
||||
Country: String(inCountry.no),
|
||||
Region: String(inRegion.no),
|
||||
City: String(inZone.no),
|
||||
Wereda: String(inWoreda.no),
|
||||
type Name = string | null | undefined;
|
||||
const below = (zone: Name, woreda: Name): MorGeoCodes => {
|
||||
const inZone = matchLevel(inRegion.rows, "zone", zone, { region: regionScope }, input);
|
||||
const zoneScope = normalizeName(inZone.rows[0][SLOTS.zone.name] as string);
|
||||
const inWoreda = matchLevel(
|
||||
inZone.rows,
|
||||
"woreda",
|
||||
woreda,
|
||||
{
|
||||
region: regionScope,
|
||||
zone: zoneScope,
|
||||
},
|
||||
input,
|
||||
);
|
||||
return {
|
||||
Country: String(inCountry.no),
|
||||
Region: String(inRegion.no),
|
||||
City: String(inZone.no),
|
||||
Wereda: String(inWoreda.no),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
return below(input.zone, input.woreda);
|
||||
} catch (err) {
|
||||
// Addis Ababa (and every other chartered city) has no zone tier: MoR's CITY level *is* the
|
||||
// sub-city and its LOCALITY level is the numbered woreda. e-Trade fills the missing tier by
|
||||
// repeating the region in `zone`, which pushes the sub-city into `woreda` and the woreda
|
||||
// number into `kebele` — one level down the whole way. Retry with that reading, but only when
|
||||
// `zone` genuinely repeats the region, and only accept it when *both* shifted levels resolve
|
||||
// exactly. A zone MoR simply does not list still fails with its own message, unreinterpreted.
|
||||
const zone = normalizeName(input.zone);
|
||||
if (!zone || (zone !== regionScope && zone !== normalizeName(input.region))) throw err;
|
||||
try {
|
||||
return below(input.woreda, input.kebele);
|
||||
} catch {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Non-throwing variant for callers that already have a working fallback (the seller identity). */
|
||||
|
||||
@@ -134,6 +134,7 @@ export class EimsBulkRegistrationService {
|
||||
region: invoice.company?.region,
|
||||
zone: invoice.company?.zone,
|
||||
woreda: invoice.company?.woreda,
|
||||
kebele: invoice.company?.kebele,
|
||||
});
|
||||
return { invoice, documentType, relatedDocument, buyerGeo };
|
||||
});
|
||||
|
||||
@@ -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: <T>(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");
|
||||
});
|
||||
});
|
||||
@@ -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<TRequest, TResponse>(path: string, request: TRequest): Promise<TResponse> {
|
||||
return this.send<TRequest, TResponse>(path, request, false, false);
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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<EimsVerifyResponse> {
|
||||
const response = await this.client.postBearer<EimsVerifyRequest, EimsVerifyResponse>(
|
||||
const response = await this.client.postSigned<EimsVerifyRequest, EimsVerifyResponse>(
|
||||
"/v1/verify",
|
||||
{ irn },
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> = {}): 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<string, unknown>;
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, StationWorkLogJson> | null;
|
||||
stationWorkLogs: Record<string, StationWorkLog> | 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, {
|
||||
|
||||
100
apps/edr-freight-api/src/scripts/seed-mor-test-buyers.ts
Normal file
100
apps/edr-freight-api/src/scripts/seed-mor-test-buyers.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { AppDataSource } from "../data-source";
|
||||
|
||||
/**
|
||||
* Seeds the 30 test taxpayers the Ministry of Revenues issued for the EIMS/BSP
|
||||
* **non-self buyer** certification run — the checklist item that needs a real
|
||||
* invoice filed against a buyer that is not EDR itself.
|
||||
*
|
||||
* Every field comes from MoR's own roster, except the two location codes, which
|
||||
* do not survive contact with the Ministry's own location master:
|
||||
*
|
||||
* - MoR's sheet gives `Region = "1"`. `PARISH_NO 1` is not an Ethiopian region
|
||||
* at all (it is Djibouti), so the region is stored by name — `Bole` is an
|
||||
* Addis Ababa sub-city, which fixes the region unambiguously (PARISH_NO 13).
|
||||
* - MoR's sheet gives `City = "101"`, which is GOFA ZONE in SNNPRS. The buyer
|
||||
* is in Bole, so the sub-city is stored by name (CITY_NO 78).
|
||||
*
|
||||
* In other words MoR does not validate the geographic codes it sends itself;
|
||||
* these rows carry the addresses that actually resolve. The roster carries no
|
||||
* woreda, so every row takes `NO WOREDA-144` — MoR's *own* "not specified"
|
||||
* locality under BOLE (LOCALITY_NO 574), the same code EDR's static seller
|
||||
* details already file under. Nothing here is invented.
|
||||
*
|
||||
* Idempotent: `ON CONFLICT (tin) DO NOTHING`. TIN 0089238373 is already on file
|
||||
* as a real customer (Afri Software Solutions) and is deliberately left alone.
|
||||
*/
|
||||
|
||||
/**
|
||||
* `[TIN, phone, legal name, email, kebele?, house number?]`, verbatim from MoR's roster. The two
|
||||
* trailing fields default to the values 22 of the 30 rows share.
|
||||
*/
|
||||
const KEBELE = "Near Bole Airport";
|
||||
const HOUSE_NO = "123B";
|
||||
|
||||
const MOR_TEST_BUYERS: Array<[string, string, string, string, string?, string?]> = [
|
||||
["0089238373", "251911091245", "Taxpayer A", "codethicaet@gmail.com", "Near Airport", "101"],
|
||||
["0054864576", "251911091245", "Taxpayer B", "shehir8@gmail.com", "Near Airport"],
|
||||
["0049056594", "251911091245", "Taxpayer C", "teme@odooethiopia.com", "Near Airport"],
|
||||
["0059819904", "251911091245", "Taxpayer D", "rubiethoplc@gmail.com", "Near Airport"],
|
||||
["0000018932", "251911091245", "Taxpayer E", "amanuelephremedu@gmail.com", "Near Airport"],
|
||||
["0088683375", "251911091245", "Taxpayer F", "ermiastegegn576@gmail.com", "Near Airport"],
|
||||
["0068421445", "251911091245", "Taxpayer G", "qelemmeda@gmail.com", "Near Airport"],
|
||||
["0004404844", "251911091245", "Taxpayer H", "dagnegamu24@gmail.com"],
|
||||
["0000037187", "251911091245", "Taxpayer I", "deresr.belay@gmail.com"],
|
||||
["0050167460", "251911091245", "Taxpayer J", "sera2013ec@gmail.com"],
|
||||
["0079690836", "251909978781", "Taxpayer K", "asmeradefa@gmail.com"],
|
||||
["0068180813", "251944310004", "Taxpayer L", "hailelt@gmail.com"],
|
||||
["0083907363", "251944310004", "Taxpayer M", "dawitfissha1@gmail.com"],
|
||||
["0089032785", "251944310004", "Taxpayer N", "tewahido11@gmail.com"],
|
||||
["0003826418", "251944310004", "Taxpayer O", "alemayehu.t@marakisoft.com"],
|
||||
["0053374665", "251944310004", "Taxpayer P", "getlelaw@gmail.com"],
|
||||
["0016175194", "251911463482", "Taxpayer Q", "abiye.abi@gmail.com", "Near Airport"],
|
||||
["0094542975", "251911463482", "Taxpayer R", "abelgebreananya@gmail.com"],
|
||||
// MoR's roster carries an 11-digit phone here; kept verbatim rather than "corrected".
|
||||
["0088514835", "25191124368", "Taxpayer S", "ewnget77@gmail.com"],
|
||||
["0076217301", "251960403750", "Taxpayer T", "merontamirat.redcloud@gmail.com"],
|
||||
["0003826419", "251911516507", "Taxpayer 322", "alemayehu.t@marakisoft.com"],
|
||||
["0056961577", "251929020729", "Taxpayer 323", "ltictsolution@gmail.com", undefined, "1234B"],
|
||||
["0090853345", "251911376145", "Taxpayer 324", "kidusgoshu2be@gmail.com"],
|
||||
["0000028643", "251988899003", "Taxpayer 325", "mesaysisay10@gmail.com"],
|
||||
["0057751727", "251911437928", "Taxpayer 326", "zewdugeta@gmail.com"],
|
||||
["0082549522", "251907256543", "Taxpayer 327", "brookgm2@gmail.com"],
|
||||
["0093283311", "251953915419", "Taxpayer 328", "henock.ad@gmail.com"],
|
||||
["0078795374", "251911091245", "Taxpayer 329", "danielltadesse@gmail.com"],
|
||||
["0093346931", "251935724920", "Taxpayer 330", "halidabd63@gmail.com"],
|
||||
["0040887091", "251913792959", "Taxpayer 331", "milextech@gmail.com"],
|
||||
];
|
||||
|
||||
async function seedMorTestBuyers(): Promise<void> {
|
||||
await AppDataSource.initialize();
|
||||
try {
|
||||
for (const [tin, phone, name, email, kebele, houseNo] of MOR_TEST_BUYERS) {
|
||||
await AppDataSource.query(
|
||||
`INSERT INTO freight.companies
|
||||
(name, type, kind, status, tin, country, region, zone, woreda, kebele,
|
||||
house_no, phone, email)
|
||||
VALUES ($1, 'customer', 'commercial', 'active', $2, 'Ethiopia', 'Addis Ababa', 'Bole',
|
||||
'NO WOREDA-144', $3, $4, $5, $6)
|
||||
ON CONFLICT (tin) DO NOTHING`,
|
||||
[name, tin, kebele ?? KEBELE, houseNo ?? HOUSE_NO, phone, email],
|
||||
);
|
||||
}
|
||||
|
||||
const summary = await AppDataSource.query(
|
||||
`SELECT count(*)::int AS on_file,
|
||||
count(*) FILTER (WHERE name LIKE 'Taxpayer %')::int AS seeded
|
||||
FROM freight.companies
|
||||
WHERE tin = ANY($1::text[])`,
|
||||
[MOR_TEST_BUYERS.map(([tin]) => tin)],
|
||||
);
|
||||
console.table(summary);
|
||||
console.log("Seeded MoR EIMS/BSP test buyers.");
|
||||
} finally {
|
||||
await AppDataSource.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
seedMorTestBuyers().catch((err) => {
|
||||
console.error("MoR test buyer seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type TrainLoadableItem,
|
||||
} from '@/services/warehouse.service';
|
||||
import { extractErrorMessage } from './options';
|
||||
import { YardLoadingWindows } from './YardLoadingWindows';
|
||||
|
||||
const STAGE_COLOR: Record<string, string> = {
|
||||
RECEIVED: 'blue',
|
||||
@@ -161,6 +162,11 @@ function TrainRow({ train, expanded, onToggle }: { train: LoadableTrain; expande
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
<YardLoadingWindows
|
||||
scheduleId={train.scheduleId}
|
||||
items={items}
|
||||
logs={train.stationWorkLogs}
|
||||
/>
|
||||
{bookings.map((b) => (
|
||||
<BookingBlock key={b.bookingId ?? b.bookingReference} scheduleId={train.scheduleId} booking={b} />
|
||||
))}
|
||||
|
||||
@@ -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 ? (
|
||||
<YardLoadingWindows
|
||||
scheduleId={targetScheduleId}
|
||||
items={pickerItems}
|
||||
logs={trains.find((t) => t.scheduleId === targetScheduleId)?.stationWorkLogs}
|
||||
/>
|
||||
) : null}
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setTrainPickerOpen(false)} disabled={loadOntoTrain.isPending}>
|
||||
Cancel
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Alert, Badge, Group, Stack, Text } from '@mantine/core';
|
||||
import { Info, MapPin } from 'lucide-react';
|
||||
|
||||
import { StationWorkControls } from '@/components/trainScheduling/StationWorkControls';
|
||||
import type { TrainLoadableItem } from '@/services/warehouse.service';
|
||||
import type { StationWorkLog } from '@/types/trainScheduling';
|
||||
|
||||
/**
|
||||
* The train schedule's per-yard loading window, shown where the warehouse
|
||||
* actually loads. Cargo may only go onto a wagon inside a started window
|
||||
* (assertStationWorkStarted on the API side), so the same Start/End loading
|
||||
* controls the train schedule page carries belong here too — otherwise the
|
||||
* warehouse operator sees a Load button that the server refuses.
|
||||
*
|
||||
* One block per boarding yard of the items waiting to load, since a train can
|
||||
* pick cargo up at more than one stop and each stop has its own window.
|
||||
*/
|
||||
export function YardLoadingWindows({
|
||||
scheduleId,
|
||||
items,
|
||||
logs,
|
||||
}: {
|
||||
scheduleId: string;
|
||||
items: TrainLoadableItem[];
|
||||
logs: Record<string, StationWorkLog> | null | undefined;
|
||||
}) {
|
||||
const yards = useMemo(() => {
|
||||
const byYard = new Map<string, { label: string; count: number }>();
|
||||
for (const item of items) {
|
||||
// Everything still queued for this train, not only the ready ones — the
|
||||
// operator starts the window before the cargo finishes becoming ready.
|
||||
if (item.status === 'LOADED' || !item.originYardId) continue;
|
||||
const entry = byYard.get(item.originYardId);
|
||||
if (entry) entry.count += 1;
|
||||
else byYard.set(item.originYardId, { label: item.originYardLabel ?? 'Boarding yard', count: 1 });
|
||||
}
|
||||
return [...byYard.entries()].map(([yardId, v]) => ({ yardId, ...v }));
|
||||
}, [items]);
|
||||
|
||||
if (yards.length === 0) return null;
|
||||
|
||||
const anyOpen = yards.some((y) => logs?.[y.yardId]?.loading?.startedAt);
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
Loading window
|
||||
</Text>
|
||||
{!anyOpen ? (
|
||||
<Alert color="yellow" variant="light" icon={<Info size={16} />}>
|
||||
Start loading at the yard before loading cargo — the train schedule records the same
|
||||
window, and the server refuses cargo outside it.
|
||||
</Alert>
|
||||
) : null}
|
||||
{yards.map((yard) => (
|
||||
<Stack key={yard.yardId} gap={6} p="xs" style={{ border: '1px solid var(--mantine-color-gray-3)', borderRadius: 8 }}>
|
||||
<Group gap={8} align="center">
|
||||
<MapPin size={13} />
|
||||
<Text size="xs" fw={700}>
|
||||
{yard.label}
|
||||
</Text>
|
||||
<Badge size="sm" radius="sm" variant="light" color="gray">
|
||||
{yard.count} queued
|
||||
</Badge>
|
||||
</Group>
|
||||
<StationWorkControls
|
||||
scheduleId={scheduleId}
|
||||
yardId={yard.yardId}
|
||||
phase="loading"
|
||||
log={logs?.[yard.yardId]?.loading}
|
||||
/>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -295,6 +295,11 @@ const INVENTORY_INVALIDATIONS: ReadonlyArray<readonly unknown[]> = [
|
||||
const TRAIN_SCHEDULING_INVALIDATIONS: ReadonlyArray<readonly unknown[]> = [
|
||||
QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
|
||||
QUERY_KEYS.BOOKINGS.ROOT,
|
||||
// A station's loading window gates the warehouse loading queues too — they
|
||||
// render the same Start/End controls, so they must refresh on the same click.
|
||||
["loadable-trains"],
|
||||
["train-loadable-items"],
|
||||
["warehouse-inventory"],
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -82,6 +82,7 @@ import type {
|
||||
WarehouseZone,
|
||||
ZoneContentItem,
|
||||
} from '@/types/warehouse';
|
||||
import type { StationWorkLog } from '@/types/trainScheduling';
|
||||
|
||||
export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
||||
|
||||
@@ -115,6 +116,10 @@ export interface LoadableTrain {
|
||||
departureTime: string | null;
|
||||
readyCount: number;
|
||||
loadedCount: number;
|
||||
/** freight.yards.id the train departs from. */
|
||||
originStationId: string | null;
|
||||
/** The schedule's per-yard loading/unloading windows — same store the train schedule page writes. */
|
||||
stationWorkLogs: Record<string, StationWorkLog> | null;
|
||||
}
|
||||
|
||||
/** A container/cargo inventory item assigned to a train, with its allocated wagon. */
|
||||
@@ -132,6 +137,11 @@ export interface TrainLoadableItem {
|
||||
wagonId: string | null;
|
||||
wagonNumber: string | null;
|
||||
sequenceNo: number | null;
|
||||
/** The booking's boarding yard — the yard whose loading window gates this item. */
|
||||
originYardId: string | null;
|
||||
originYardLabel: string | null;
|
||||
/** True once "Start loading" was clicked for this item's boarding yard on this train. */
|
||||
loadingWindowStarted: boolean;
|
||||
loadable: boolean;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user