mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 22:58:17 +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: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: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: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",
|
"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: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",
|
"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", 976, "Wal-Mera"],
|
||||||
[70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 909, "Akaki woreda"],
|
[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", 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"],
|
[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", () => {
|
describe("failures happen locally, before anything is filed", () => {
|
||||||
const cases: Array<[string, Record<string, string>, RegExp]> = [
|
const cases: Array<[string, Record<string, string>, RegExp]> = [
|
||||||
["unknown country", { ...JIJIGA, country: "Wakanda" }, /no MoR COUNTRY_NAME match/],
|
["unknown country", { ...JIJIGA, country: "Wakanda" }, /no MoR COUNTRY_NAME match/],
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ export interface MorAddressInput {
|
|||||||
region?: string | null;
|
region?: string | null;
|
||||||
zone?: string | null;
|
zone?: string | null;
|
||||||
woreda?: 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";
|
type Level = "country" | "region" | "zone" | "woreda";
|
||||||
@@ -115,6 +120,24 @@ const ALIASES: MorAlias[] = [
|
|||||||
from: "Jigjiga",
|
from: "Jigjiga",
|
||||||
to: "JIJIGA",
|
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[] =>
|
const zoneSuffixCandidates = (normalized: string): string[] =>
|
||||||
normalized.endsWith(" ZONE") ? [] : [`${normalized} ZONE`];
|
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 {
|
export class MorGeoMappingError extends BadRequestException {
|
||||||
constructor(code: "EIMS_GEO_MAPPING_FAILED" | "EIMS_GEO_AMBIGUOUS", message: string) {
|
constructor(code: "EIMS_GEO_MAPPING_FAILED" | "EIMS_GEO_AMBIGUOUS", message: string) {
|
||||||
super({ code, message });
|
super({ code, message });
|
||||||
@@ -158,6 +195,7 @@ function matchLevel(
|
|||||||
if (normalizeName(alias.from) === wanted) candidates.push(normalizeName(alias.to));
|
if (normalizeName(alias.from) === wanted) candidates.push(normalizeName(alias.to));
|
||||||
}
|
}
|
||||||
if (level === "zone") candidates.push(...zoneSuffixCandidates(wanted));
|
if (level === "zone") candidates.push(...zoneSuffixCandidates(wanted));
|
||||||
|
if (level === "woreda") candidates.push(...woredaNumberCandidates(wanted));
|
||||||
}
|
}
|
||||||
|
|
||||||
let matched: MorLocationTuple[] = [];
|
let matched: MorLocationTuple[] = [];
|
||||||
@@ -221,25 +259,46 @@ export function resolveMorGeo(
|
|||||||
const inCountry = matchLevel(rows, "country", country, {}, input);
|
const inCountry = matchLevel(rows, "country", country, {}, input);
|
||||||
const inRegion = matchLevel(inCountry.rows, "region", input.region, {}, input);
|
const inRegion = matchLevel(inCountry.rows, "region", input.region, {}, input);
|
||||||
const regionScope = normalizeName(inRegion.rows[0][SLOTS.region.name] as string);
|
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 {
|
type Name = string | null | undefined;
|
||||||
Country: String(inCountry.no),
|
const below = (zone: Name, woreda: Name): MorGeoCodes => {
|
||||||
Region: String(inRegion.no),
|
const inZone = matchLevel(inRegion.rows, "zone", zone, { region: regionScope }, input);
|
||||||
City: String(inZone.no),
|
const zoneScope = normalizeName(inZone.rows[0][SLOTS.zone.name] as string);
|
||||||
Wereda: String(inWoreda.no),
|
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). */
|
/** 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,
|
region: invoice.company?.region,
|
||||||
zone: invoice.company?.zone,
|
zone: invoice.company?.zone,
|
||||||
woreda: invoice.company?.woreda,
|
woreda: invoice.company?.woreda,
|
||||||
|
kebele: invoice.company?.kebele,
|
||||||
});
|
});
|
||||||
return { invoice, documentType, relatedDocument, buyerGeo };
|
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`, …).
|
* 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
|
* 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()
|
@Injectable()
|
||||||
export class EimsClientService {
|
export class EimsClientService {
|
||||||
@@ -39,10 +39,15 @@ export class EimsClientService {
|
|||||||
/**
|
/**
|
||||||
* POST `request` verbatim — bearer-authenticated but **not** wrapped in a signed envelope.
|
* 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
|
* The supplied collection sends raw bodies for `/v1/verify`, `/v1/cancel` and the receipt
|
||||||
* raw `{"irn":"…"}` body with no `signature`/`certificate` siblings. Kept as its own entry point
|
* endpoints. That turned out to be wrong for `/v1/verify`, which the live gateway rejects with
|
||||||
* so that if the live gateway turns out to require signing after all, exactly one call site
|
* `GATEWAY ERROR code=4001` (`request`/`signature`/`certificate` must not be null) until the
|
||||||
* changes — `postSigned` is already the alternative.
|
* 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> {
|
async postBearer<TRequest, TResponse>(path: string, request: TRequest): Promise<TResponse> {
|
||||||
return this.send<TRequest, TResponse>(path, request, false, false);
|
return this.send<TRequest, TResponse>(path, request, false, false);
|
||||||
|
|||||||
@@ -759,38 +759,60 @@ describe("EimsInvoiceRegistrationService staff alerting", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => {
|
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 db = new FakeDb([invoiceRow({ eimsIrn: IRN })]);
|
||||||
const postSigned = jest.fn();
|
const postBearer = jest.fn();
|
||||||
const postBearer = jest.fn().mockResolvedValue(verifyResponse());
|
const postSigned = jest.fn().mockResolvedValue(verifyResponse());
|
||||||
|
|
||||||
const result = await build(db, postSigned, config(), postBearer).verifyInvoiceWithEims(
|
const result = await build(db, postSigned, config(), postBearer).verifyInvoiceWithEims(
|
||||||
INVOICE_ID,
|
INVOICE_ID,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Lowercase `irn`, raw body — not a signed envelope. `postSigned` must stay untouched.
|
// Lowercase `irn`, signed envelope — the live gateway rejects the unsigned body with
|
||||||
expect(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN });
|
// `code=4001` naming request/signature/certificate as null.
|
||||||
expect(postSigned).not.toHaveBeenCalled();
|
expect(postSigned).toHaveBeenCalledWith("/v1/verify", { irn: IRN });
|
||||||
|
expect(postBearer).not.toHaveBeenCalled();
|
||||||
expect(result.body).toMatchObject({ Irn: IRN });
|
expect(result.body).toMatchObject({ Irn: IRN });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a 200 that carries no Irn", async () => {
|
it("rejects a 200 that carries no Irn", async () => {
|
||||||
const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]);
|
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(
|
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/);
|
).rejects.toThrow(/returned no Irn/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refuses to verify an invoice with no IRN", async () => {
|
it("refuses to verify an invoice with no IRN", async () => {
|
||||||
const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Unknown })]);
|
const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Unknown })]);
|
||||||
const postBearer = jest.fn();
|
const postSigned = jest.fn();
|
||||||
|
|
||||||
await expect(
|
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/);
|
).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 () => {
|
it("records a confirmed IRN, resumes the chain and clears the block", async () => {
|
||||||
const db = blocked();
|
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,
|
INVOICE_ID,
|
||||||
{ irn: IRN },
|
{ irn: IRN },
|
||||||
);
|
);
|
||||||
|
|
||||||
// The IRN is confirmed at the gateway before it is ever written.
|
// 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(view).toMatchObject({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: IRN });
|
||||||
expect(db.state).toMatchObject({
|
expect(db.state).toMatchObject({
|
||||||
previousIrn: IRN,
|
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 () => {
|
it("refuses an IRN the gateway answers with a different one, leaving the block intact", async () => {
|
||||||
const db = blocked();
|
const db = blocked();
|
||||||
const postBearer = jest
|
const postSigned = jest
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue(verifyResponse({ Irn: "0000000000000000000000000000000000000000" }));
|
.mockResolvedValue(verifyResponse({ Irn: "0000000000000000000000000000000000000000" }));
|
||||||
|
|
||||||
await expect(
|
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/);
|
).rejects.toThrow(/answered the lookup for IRN/);
|
||||||
|
|
||||||
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
|
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 () => {
|
it("refuses an IRN whose document number is not this invoice, leaving the block intact", async () => {
|
||||||
const db = blocked();
|
const db = blocked();
|
||||||
const postBearer = jest.fn().mockResolvedValue(
|
const postSigned = jest.fn().mockResolvedValue(
|
||||||
verifyResponse({
|
verifyResponse({
|
||||||
DocumentDetails: { Type: "INV", DocumentNumber: "99999" },
|
DocumentDetails: { Type: "INV", DocumentNumber: "99999" },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
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/);
|
).rejects.toThrow(/not 5/);
|
||||||
|
|
||||||
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
|
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 () => {
|
it("refuses an IRN the gateway does not acknowledge at all", async () => {
|
||||||
const db = blocked();
|
const db = blocked();
|
||||||
const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: {} });
|
const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: {} });
|
||||||
|
|
||||||
await expect(
|
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/);
|
).rejects.toThrow(/returned no Irn/);
|
||||||
expect(db.state!.blockedReason).toBe("never acknowledged");
|
expect(db.state!.blockedReason).toBe("never acknowledged");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("discards the attempt, leaving the chain where it was", async () => {
|
it("discards the attempt, leaving the chain where it was", async () => {
|
||||||
const db = blocked();
|
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,
|
INVOICE_ID,
|
||||||
{ discard: true },
|
{ discard: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(view).toMatchObject({ eimsStatus: EimsInvoiceStatus.Failed, eimsIrn: null });
|
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({
|
expect(db.state).toMatchObject({
|
||||||
previousIrn: null,
|
previousIrn: null,
|
||||||
inFlightInvoiceId: null,
|
inFlightInvoiceId: null,
|
||||||
@@ -908,10 +930,10 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
|
|||||||
OTHER_INVOICE_ID,
|
OTHER_INVOICE_ID,
|
||||||
invoiceRow({ id: OTHER_INVOICE_ID, eimsDocumentNumber: "6" }),
|
invoiceRow({ id: OTHER_INVOICE_ID, eimsDocumentNumber: "6" }),
|
||||||
);
|
);
|
||||||
const postBearer = jest.fn().mockResolvedValue(verifyResponse());
|
const postSigned = jest.fn().mockResolvedValue(verifyResponse());
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(OTHER_INVOICE_ID, {
|
build(db, postSigned, config(), jest.fn()).resolveEimsRegistration(OTHER_INVOICE_ID, {
|
||||||
irn: IRN,
|
irn: IRN,
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow(/in-flight EIMS submission is invoice/);
|
).rejects.toThrow(/in-flight EIMS submission is invoice/);
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ export class EimsInvoiceRegistrationService {
|
|||||||
region: invoice.company?.region,
|
region: invoice.company?.region,
|
||||||
zone: invoice.company?.zone,
|
zone: invoice.company?.zone,
|
||||||
woreda: invoice.company?.woreda,
|
woreda: invoice.company?.woreda,
|
||||||
|
kebele: invoice.company?.kebele,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Authenticate before reserving: the source system comes from the token, and the state row is
|
// 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,
|
* 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.
|
* 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> {
|
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",
|
"/v1/verify",
|
||||||
{ irn },
|
{ irn },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ export class EimsSellerCacheService implements OnModuleInit {
|
|||||||
region: data.region,
|
region: data.region,
|
||||||
zone: data.zone,
|
zone: data.zone,
|
||||||
woreda: data.woreda,
|
woreda: data.woreda,
|
||||||
|
kebele: data.kebele,
|
||||||
});
|
});
|
||||||
this.cached = {
|
this.cached = {
|
||||||
// The *legal* entity name, not the licence's trade name that
|
// 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 { generateGrnNumber } from '../../common/grn.util';
|
||||||
import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql';
|
import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql';
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
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 { Cargo } from '../cargoes/entities/cargoes.entity';
|
||||||
import { Company } from '../companies/entities/company.entity';
|
import { Company } from '../companies/entities/company.entity';
|
||||||
import { Container } from '../container-management/entities/container.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
|
* schedule page stores them. The warehouse loading queues render the same
|
||||||
* Start/End controls off this, so both surfaces show one truth.
|
* 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. */
|
/** Received/ready inventory not yet loaded onto this train. */
|
||||||
readyCount: number;
|
readyCount: number;
|
||||||
/** Inventory already loaded onto this train. */
|
/** Inventory already loaded onto this train. */
|
||||||
@@ -1855,6 +1856,8 @@ export class WarehouseInventoryService {
|
|||||||
dy.country AS "destinationCountry",
|
dy.country AS "destinationCountry",
|
||||||
ts.status AS "status",
|
ts.status AS "status",
|
||||||
ts.scheduled_departure_date AS "departureTime",
|
ts.scheduled_departure_date AS "departureTime",
|
||||||
|
ts.origin_station_id AS "originStationId",
|
||||||
|
ts.station_work_logs AS "stationWorkLogs",
|
||||||
(SELECT count(*) FROM sched_bookings sb
|
(SELECT count(*) FROM sched_bookings sb
|
||||||
JOIN freight.warehouse_inventory inv
|
JOIN freight.warehouse_inventory inv
|
||||||
ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL
|
ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL
|
||||||
@@ -1918,7 +1921,15 @@ export class WarehouseInventoryService {
|
|||||||
inv.status AS "status",
|
inv.status AS "status",
|
||||||
wl.wagon_id AS "wagonId",
|
wl.wagon_id AS "wagonId",
|
||||||
wl.wagon_number AS "wagonNumber",
|
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
|
FROM sched_bookings sb
|
||||||
JOIN freight.train_schedules ts ON ts.id = sb.schedule_id
|
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
|
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.companies company ON company.id = b.company_id
|
||||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_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.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 (
|
LEFT JOIN LATERAL (
|
||||||
SELECT w.id AS wagon_id, w.wagon_number, tsw.sequence_no
|
SELECT w.id AS wagon_id, w.wagon_number, tsw.sequence_no
|
||||||
FROM freight.wagon_booking_allocations wba
|
FROM freight.wagon_booking_allocations wba
|
||||||
@@ -1948,9 +1960,14 @@ export class WarehouseInventoryService {
|
|||||||
...r,
|
...r,
|
||||||
// Export flow: received at the warehouse -> GRN -> loaded onto its wagon.
|
// 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
|
// 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:
|
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.
|
// nothing rides a train without one.
|
||||||
if (!item.grnNumber) { skip('No GRN — receive the goods and generate the GRN first'); continue; }
|
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; }
|
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 {
|
try {
|
||||||
await this.load(inventoryId, {
|
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,
|
type TrainLoadableItem,
|
||||||
} from '@/services/warehouse.service';
|
} from '@/services/warehouse.service';
|
||||||
import { extractErrorMessage } from './options';
|
import { extractErrorMessage } from './options';
|
||||||
|
import { YardLoadingWindows } from './YardLoadingWindows';
|
||||||
|
|
||||||
const STAGE_COLOR: Record<string, string> = {
|
const STAGE_COLOR: Record<string, string> = {
|
||||||
RECEIVED: 'blue',
|
RECEIVED: 'blue',
|
||||||
@@ -161,6 +162,11 @@ function TrainRow({ train, expanded, onToggle }: { train: LoadableTrain; expande
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
|
<YardLoadingWindows
|
||||||
|
scheduleId={train.scheduleId}
|
||||||
|
items={items}
|
||||||
|
logs={train.stationWorkLogs}
|
||||||
|
/>
|
||||||
{bookings.map((b) => (
|
{bookings.map((b) => (
|
||||||
<BookingBlock key={b.bookingId ?? b.bookingReference} scheduleId={train.scheduleId} booking={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 { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||||
import { StoreInventoryModal } from './StoreInventoryModal';
|
import { StoreInventoryModal } from './StoreInventoryModal';
|
||||||
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
||||||
|
import { YardLoadingWindows } from './YardLoadingWindows';
|
||||||
import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions, warehousesAtStation, yardsForBooking } from './options';
|
import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions, warehousesAtStation, yardsForBooking } from './options';
|
||||||
import { openPdfBlob } from './pdf';
|
import { openPdfBlob } from './pdf';
|
||||||
import ListControls from '@/components/common/ListControls';
|
import ListControls from '@/components/common/ListControls';
|
||||||
@@ -1598,6 +1599,11 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
|||||||
queryFn: () => warehouseService.getLoadableTrains(),
|
queryFn: () => warehouseService.getLoadableTrains(),
|
||||||
enabled: enabled && trainPickerOpen,
|
enabled: enabled && trainPickerOpen,
|
||||||
});
|
});
|
||||||
|
const { data: pickerItems = [] } = useQuery({
|
||||||
|
queryKey: ['train-loadable-items', targetScheduleId],
|
||||||
|
queryFn: () => warehouseService.getTrainLoadableItems(targetScheduleId!),
|
||||||
|
enabled: Boolean(targetScheduleId) && trainPickerOpen,
|
||||||
|
});
|
||||||
const loadOntoTrain = useMutation({
|
const loadOntoTrain = useMutation({
|
||||||
mutationFn: async ({ scheduleId, onlyIds }: { scheduleId: string; onlyIds: string[] }) => {
|
mutationFn: async ({ scheduleId, onlyIds }: { scheduleId: string; onlyIds: string[] }) => {
|
||||||
const items = await warehouseService.getTrainLoadableItems(scheduleId);
|
const items = await warehouseService.getTrainLoadableItems(scheduleId);
|
||||||
@@ -1608,6 +1614,17 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
|||||||
loadableIds = loadableIds.filter((id) => picked.has(id));
|
loadableIds = loadableIds.filter((id) => picked.has(id));
|
||||||
}
|
}
|
||||||
if (!loadableIds.length) {
|
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(
|
throw new Error(
|
||||||
onlyIds.length
|
onlyIds.length
|
||||||
? 'None of the selected items have an allocated wagon on this train'
|
? 'None of the selected items have an allocated wagon on this train'
|
||||||
@@ -1701,6 +1718,13 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
|||||||
searchable
|
searchable
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{targetScheduleId ? (
|
||||||
|
<YardLoadingWindows
|
||||||
|
scheduleId={targetScheduleId}
|
||||||
|
items={pickerItems}
|
||||||
|
logs={trains.find((t) => t.scheduleId === targetScheduleId)?.stationWorkLogs}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button variant="default" onClick={() => setTrainPickerOpen(false)} disabled={loadOntoTrain.isPending}>
|
<Button variant="default" onClick={() => setTrainPickerOpen(false)} disabled={loadOntoTrain.isPending}>
|
||||||
Cancel
|
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[]> = [
|
const TRAIN_SCHEDULING_INVALIDATIONS: ReadonlyArray<readonly unknown[]> = [
|
||||||
QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
|
QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
|
||||||
QUERY_KEYS.BOOKINGS.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,
|
WarehouseZone,
|
||||||
ZoneContentItem,
|
ZoneContentItem,
|
||||||
} from '@/types/warehouse';
|
} from '@/types/warehouse';
|
||||||
|
import type { StationWorkLog } from '@/types/trainScheduling';
|
||||||
|
|
||||||
export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
||||||
|
|
||||||
@@ -115,6 +116,10 @@ export interface LoadableTrain {
|
|||||||
departureTime: string | null;
|
departureTime: string | null;
|
||||||
readyCount: number;
|
readyCount: number;
|
||||||
loadedCount: 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. */
|
/** A container/cargo inventory item assigned to a train, with its allocated wagon. */
|
||||||
@@ -132,6 +137,11 @@ export interface TrainLoadableItem {
|
|||||||
wagonId: string | null;
|
wagonId: string | null;
|
||||||
wagonNumber: string | null;
|
wagonNumber: string | null;
|
||||||
sequenceNo: number | 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;
|
loadable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user