Merge pull request #1469 from Tria-plc/Wrehousechanges

Mor Verify fix
fix(eims): sign the /v1/verify request body
This commit is contained in:
Hagernesh Tadesse
2026-09-01 06:45:50 +03:00
committed by GitHub
17 changed files with 696 additions and 53 deletions

View File

@@ -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",

View File

@@ -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/],

View File

@@ -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). */

View File

@@ -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 };
});

View File

@@ -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");
});
});

View File

@@ -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);

View File

@@ -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/);

View File

@@ -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 },
);

View File

@@ -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

View File

@@ -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');
});
});

View File

@@ -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, {

View 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);
});

View File

@@ -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} />
))}

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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"],
];
/**

View File

@@ -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;
}