Merge pull request #1404 from Tria-plc/staging

Staging
This commit is contained in:
marshal
2026-08-26 01:29:45 +03:00
committed by GitHub
217 changed files with 12684 additions and 1475 deletions

BIN
4_5767239985799371288.xlsx Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
INV-20260812-00005-QR.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
INV-20260812-00005.pdf Normal file

Binary file not shown.

View File

@@ -202,14 +202,13 @@ EIMS_NATURE_OF_SUPPLIES=service
EIMS_PAYMENT_MODE=CASH
EIMS_PAYMENT_TERM=IMMIDIATE
EIMS_UNIT_DEFAULT=PCS
# MoR numeric country code for the buyer; our companies store the country name.
EIMS_BUYER_COUNTRY_CODE=
# Buyer region name -> MoR numeric code. companies.region holds names; MoR wants ^[0-9]{1,3}$.
# An unmapped region fails locally rather than being filed with a guess.
EIMS_BUYER_REGION_CODES=Addis Ababa=13
# Same mechanism for Wereda. MoR has never named a Wereda regex in an error (only Region's is
# confirmed), so this is precautionary — but an unmapped name still fails locally, not filed as a guess.
EIMS_BUYER_WEREDA_CODES=
# Buyer Country/Region/City/Wereda are NOT configured here any more. They are resolved from the
# Ministry's own location master (EIMS_COUNTRY_REGION_VW), committed as
# src/config/mor-locations.data.ts and regenerated with:
# pnpm --filter @edr/freight-api eims:import-locations <workbook.xlsx>
# The removed EIMS_BUYER_COUNTRY_CODE / _COUNTRY_CODES / _REGION_CODES / _CITY_CODES /
# _WEREDA_CODES maps are ignored if still set — MoR reference data is the only source, and an env
# var must not be able to override an official code. Delete them from your deployment config.
EIMS_CASHIER_NAME=
EIMS_SALESPERSON_NAME=
# Automatic filing of issued invoices (@Cron sweep, one invoice per tick).

View File

@@ -38,7 +38,8 @@
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
"migration:run": "nest build && node dist/scripts/migrate.js",
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts",
"eims:login": "ts-node -r tsconfig-paths/register src/scripts/eims-login.ts"
"eims:login": "ts-node -r tsconfig-paths/register src/scripts/eims-login.ts",
"eims:import-locations": "ts-node -r tsconfig-paths/register src/scripts/import-mor-locations.ts"
},
"dependencies": {
"@edr/api-common": "workspace:*",

View File

@@ -1,5 +1,5 @@
import { applyDecorators, UseGuards } from '@nestjs/common';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { FreightJwtGuard } from './freight-jwt.guard';
import {
FreightPermissionGuard,
@@ -11,7 +11,7 @@ import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
export const BookingStaff = (permission: string | string[]) =>
applyDecorators(
UseGuards(
JwtGuard,
FreightJwtGuard,
FreightPermissionGuard(
Array.isArray(permission) ? permission : [permission],
),
@@ -26,11 +26,11 @@ export const BookingStaff = (permission: string | string[]) =>
* BookingStaff(<view key>) or MixedAudience(); kept for routes not yet swept.
*/
export const StaffReference = () =>
applyDecorators(UseGuards(JwtGuard, FreightPermissionGuard([])));
applyDecorators(UseGuards(FreightJwtGuard, FreightPermissionGuard([])));
/** Portal routes: customer accounts only; ownership scoping stays in services. */
export const PortalCustomer = () =>
applyDecorators(UseGuards(JwtGuard, PortalCustomerGuard));
applyDecorators(UseGuards(FreightJwtGuard, PortalCustomerGuard));
/**
* Routes both audiences call (sign, shared document reads, handover): staff
@@ -40,7 +40,7 @@ export const PortalCustomer = () =>
export const MixedAudience = (permission: string | string[]) =>
applyDecorators(
UseGuards(
JwtGuard,
FreightJwtGuard,
MixedAudienceGuard(
Array.isArray(permission) ? permission : [permission],
),
@@ -95,6 +95,24 @@ export const TrainSchedulingLoad = () =>
export const TrainSchedulingUnload = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.unload);
/**
* Per-station loading/unloading time windows — the four buttons are four
* permissions so start and end can be granted to different people. The same
* endpoint that records a click also edits it (explicit `at`), so each
* permission covers editing its own timestamp too.
*/
export const TrainSchedulingLoadingStart = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.loadingStart);
export const TrainSchedulingLoadingEnd = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.loadingEnd);
export const TrainSchedulingUnloadingStart = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.unloadingStart);
export const TrainSchedulingUnloadingEnd = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.unloadingEnd);
export const TrainSchedulingCancel = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.cancel);

View File

@@ -0,0 +1,101 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { InjectDataSource } from '@nestjs/typeorm';
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { DataSource } from 'typeorm';
/** One position as the login snapshot stores it (`iam.sessions.userInfo`). */
type SnapshotPosition = { id?: string; [key: string]: unknown };
type SessionUserInfo = {
employee?: { id?: string; positions?: SnapshotPosition[] }[];
};
/**
* Like the IAM JwtGuard, but keeps the caller's SECONDARY positions.
*
* IAM models an employee as holding many positions, and the login snapshot in
* `iam.sessions.userInfo` carries all of them. `JwtGuard.parseToken` then
* collapses that to a single `employee.position` — whichever the request
* headers select, else `positions[0]` — and drops the rest. Non-delegate
* secondary positions vanish entirely, so staff holding two posts resolve to
* only one post's permissions and every check on the other one rejects them.
*
* This re-attaches the full list as `employee.positions`. `employee.position`
* is left exactly as the parent set it, so everything reading the single
* position today (audit log, delegation deadline) is unaffected; only the
* permission utils, which prefer the array, see the difference.
*/
@Injectable()
export class FreightJwtGuard extends IamJwtGuard implements CanActivate {
// ponytail: unbounded-until-TTL map, cleared wholesale when it gets big.
// Sessions are few and the value is small; swap for an LRU if that changes.
private static readonly CACHE_TTL_MS = 30_000;
private static readonly CACHE_MAX_ENTRIES = 5_000;
private readonly cache = new Map<
string,
{ positions: SnapshotPosition[]; expiresAt: number }
>();
constructor(
reflector: Reflector,
@InjectDataSource() private readonly ds: DataSource,
) {
super(reflector, ds);
}
async canActivate(context: ExecutionContext): Promise<boolean> {
if (!(await super.canActivate(context))) return false;
const user = context.switchToHttp().getRequest().user as
| TCurrentUser
| undefined;
const employee = user?.employee;
if (!employee || !user?.sessionId) return true;
const positions = await this.positionsForSession(
user.sessionId,
employee.id,
);
// Never blank out what the parent resolved: an unreadable session or a
// snapshot without positions must degrade to the single-position
// behaviour, not to no positions at all.
if (positions.length) {
(employee as { positions?: SnapshotPosition[] }).positions = positions;
}
return true;
}
/** Every position the login snapshot holds for this employee. */
private async positionsForSession(
sessionId: string,
employeeId: string | undefined,
): Promise<SnapshotPosition[]> {
const now = Date.now();
const hit = this.cache.get(sessionId);
if (hit && hit.expiresAt > now) return hit.positions;
let positions: SnapshotPosition[] = [];
try {
const rows: { userInfo: SessionUserInfo | null }[] = await this.ds.query(
`SELECT "userInfo" FROM iam.sessions WHERE id = $1`,
[sessionId],
);
const employees = rows[0]?.userInfo?.employee ?? [];
const match =
employees.find((e) => e?.id && e.id === employeeId) ?? employees[0];
positions = match?.positions ?? [];
} catch {
return []; // iam unreachable — caller keeps the parent's single position
}
if (this.cache.size >= FreightJwtGuard.CACHE_MAX_ENTRIES)
this.cache.clear();
this.cache.set(sessionId, {
positions,
expiresAt: now + FreightJwtGuard.CACHE_TTL_MS,
});
return positions;
}
}

View File

@@ -2,6 +2,7 @@ import {
assertCanApproveContractStep,
canEditContractStep,
collectPermissionKeys,
collectPositionTypeKeys,
hasFreightPermission,
setPositionTypePermissionResolver,
} from './freight-permission.util';
@@ -121,3 +122,66 @@ describe('collectPermissionKeys — position-type grants', () => {
expect(hasFreightPermission(direct, CLEARANCE)).toBe(true);
});
});
/**
* IAM lets an employee hold several positions, but the vendored `JwtGuard`
* collapses `employee.positions[]` down to a single `employee.position` and
* drops the rest — so staff on two posts resolved to one post's permissions
* and every check on the other rejected them. `FreightJwtGuard` restores the
* full list as `employee.positions`; these cover the union that depends on it.
*/
describe('multiple positions', () => {
// Shaped like the real two-post employee: GL chief AND GL director.
const twoPost = {
employee: {
// What the vendored guard leaves behind — one of the two, arbitrarily.
position: {
positionType: { key: 'djibouti-gl-chief' },
permissions: [{ key: FREIGHT_PERMS.contracts.view }],
},
// What FreightJwtGuard puts back.
positions: [
{
positionType: { key: 'djibouti-gl-chief' },
permissions: [{ key: FREIGHT_PERMS.contracts.view }],
},
{
positionType: { key: 'djibouti-gl-director' },
permissions: [{ key: FREIGHT_PERMS.bookings.view }],
},
],
},
};
it('unions permissions across every position', () => {
const keys = collectPermissionKeys(twoPost);
expect(keys).toContain(FREIGHT_PERMS.contracts.view);
expect(keys).toContain(FREIGHT_PERMS.bookings.view);
});
it('grants the secondary positions permission, not just the first', () => {
expect(hasFreightPermission(twoPost, FREIGHT_PERMS.bookings.view)).toBe(true);
});
it('answers to both position types', () => {
expect(collectPositionTypeKeys(twoPost)).toEqual(
expect.arrayContaining(['djibouti-gl-chief', 'djibouti-gl-director']),
);
});
it('does not double-count the position the guard also left singular', () => {
const keys = collectPermissionKeys(twoPost);
expect(keys.filter((k) => k === FREIGHT_PERMS.contracts.view)).toHaveLength(1);
});
it('still resolves the single position when the array is absent', () => {
// A request that skipped FreightJwtGuard must degrade to the old behaviour,
// not to no permissions at all.
const onePost = {
employee: {
position: { permissions: [{ key: FREIGHT_PERMS.contracts.view }] },
},
};
expect(hasFreightPermission(onePost, FREIGHT_PERMS.contracts.view)).toBe(true);
});
});

View File

@@ -17,6 +17,15 @@ type MeLikeUser = {
permissions?: PermissionLike[];
positionType?: PositionTypeLike | null;
};
/**
* Every position the employee holds, restored by `FreightJwtGuard`
* from the login snapshot. The IAM guard only ever sets the singular
* `position` above; without this, a second post's grants are invisible.
*/
positions?: {
permissions?: PermissionLike[];
positionType?: PositionTypeLike | null;
}[];
delegatedPositions?: { permissions?: PermissionLike[] }[];
}
| {
@@ -98,10 +107,15 @@ export function collectPermissionKeys(user: MeLikeUser | null | undefined): stri
return [...keys];
}
for (const p of employee.position?.permissions ?? []) {
if (p.key) keys.add(p.key);
// `position` is whichever single post the IAM guard selected; `positions` is
// the full set FreightJwtGuard restores. Walk both — the array is absent on
// a session the guard could not re-read, and the two overlap harmlessly.
for (const pos of [employee.position, ...(employee.positions ?? [])]) {
for (const p of pos?.permissions ?? []) {
if (p.key) keys.add(p.key);
}
addTypePermissions(pos?.positionType);
}
addTypePermissions(employee.position?.positionType);
for (const delegated of employee.delegatedPositions ?? []) {
for (const p of delegated.permissions ?? []) {
if (p.key) keys.add(p.key);
@@ -158,8 +172,10 @@ export function collectPositionTypeKeys(
return [...keys];
}
if (employee.position?.positionType?.key) {
keys.add(employee.position.positionType.key);
// Both shapes, same reason as collectPermissionKeys: an employee holding two
// posts answers to both their position types.
for (const pos of [employee.position, ...(employee.positions ?? [])]) {
if (pos?.positionType?.key) keys.add(pos.positionType.key);
}
return [...keys];
}

View File

@@ -1,5 +1,5 @@
import { applyDecorators, UseGuards } from '@nestjs/common';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { FreightJwtGuard } from './freight-jwt.guard';
import { FreightPermissionGuard } from './freight-permission.guard';
import {
@@ -10,7 +10,7 @@ import {
export const RuleEngineView = (slug: RuleEngineResourceSlug) =>
applyDecorators(
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.view(slug)])),
UseGuards(FreightJwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.view(slug)])),
);
// Granular CRUD replaces the retired coarse RuleEngineManage. Each write
@@ -18,17 +18,17 @@ export const RuleEngineView = (slug: RuleEngineResourceSlug) =>
// update on PATCH / reorder / move-order, delete on DELETE.
export const RuleEngineCreate = (slug: RuleEngineResourceSlug) =>
applyDecorators(
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.create(slug)])),
UseGuards(FreightJwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.create(slug)])),
);
export const RuleEngineUpdate = (slug: RuleEngineResourceSlug) =>
applyDecorators(
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.update(slug)])),
UseGuards(FreightJwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.update(slug)])),
);
export const RuleEngineDelete = (slug: RuleEngineResourceSlug) =>
applyDecorators(
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.delete(slug)])),
UseGuards(FreightJwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.delete(slug)])),
);
/**
@@ -38,5 +38,5 @@ export const RuleEngineDelete = (slug: RuleEngineResourceSlug) =>
*/
export const RuleEngineApprove = (slug: RuleEngineApprovableSlug) =>
applyDecorators(
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.approve(slug)])),
UseGuards(FreightJwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.approve(slug)])),
);

View File

@@ -70,62 +70,3 @@ describe("eims.config — private key / certificate resolution", () => {
);
});
});
describe("eims.config — baked-in Ethiopia region/zone/woreda codes", () => {
it("resolves a known region/wereda/zone with no env var set at all", () => {
withEnv(
{ ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" },
() => {
const cfg = eimsConfigFactory();
expect(cfg.invoice.buyerRegionCodes.Somali).toBe("05");
expect(cfg.invoice.buyerWeredaCodes["Jijiga Town"]).toBe("02");
expect(cfg.invoice.buyerCityCodes.Fafan).toBe("01");
},
);
});
it("an env var entry overrides the baked-in code for the same name", () => {
withEnv(
{
...REQUIRED,
EIMS_PRIVATE_KEY: "x",
EIMS_CERTIFICATE_PATH: "/dev/null",
EIMS_BUYER_REGION_CODES: "Somali=99",
},
() => {
expect(eimsConfigFactory().invoice.buyerRegionCodes.Somali).toBe("99");
},
);
});
it("an env var still adds a name the baked-in table doesn't have (a spelling variant)", () => {
withEnv(
{
...REQUIRED,
EIMS_PRIVATE_KEY: "x",
EIMS_CERTIFICATE_PATH: "/dev/null",
EIMS_BUYER_CITY_CODES: "Fafen=01",
},
() => {
const codes = eimsConfigFactory().invoice.buyerCityCodes;
expect(codes.Fafen).toBe("01");
expect(codes.Fafan).toBe("01"); // baked-in entry still present alongside it
},
);
});
it("resolves the bare Addis Ababa sub-city name a buyer profile actually stores, not the CSV's example-woreda name", () => {
withEnv(
{ ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" },
() => {
const codes = eimsConfigFactory().invoice.buyerWeredaCodes;
expect(codes.Bole).toBe("01");
expect(codes.Arada).toBe("01");
expect(codes.Kirkos).toBe("01");
expect(codes.Yeka).toBe("01");
expect(codes["Nifas Silk Lafto"]).toBe("13");
expect(codes["Nefas Silk-Lafto"]).toBe("13");
},
);
});
});

View File

@@ -1,6 +1,5 @@
import { registerAs } from "@nestjs/config";
import { ETHIOPIA_REGION_CODES, ETHIOPIA_WOREDA_CODES, ETHIOPIA_ZONE_CODES } from "./ethiopia-geo-codes";
/**
* Ethiopian MoR EIMS e-invoicing gateway.
@@ -99,35 +98,6 @@ export interface EimsInvoiceConfig {
paymentMode: string;
paymentTerm: string;
unitDefault: string;
/**
* Domestic fallback only — used when the buyer's `Company.country` is empty or "Ethiopia" (the
* column's own default) and not already listed in `buyerCountryCodes`. A genuinely foreign
* buyer must be in `buyerCountryCodes` by name or the mapping fails locally; this value is never
* applied to them, so an unconfigured foreign country can't silently be filed as Ethiopia.
*/
buyerCountryCode: string | null;
/**
* Country name → MoR code, from `EIMS_BUYER_COUNTRY_CODES` ("Ethiopia=231,Djibouti=071"). Format
* unconfirmed (unlike Region/Wereda, MoR has never named a Country regex), so — unlike them —
* this is not validated against a fixed digit pattern, only looked up by name.
*/
buyerCountryCodes: Record<string, string>;
/**
* Buyer region name → MoR numeric code, from `EIMS_BUYER_REGION_CODES`
* ("Addis Ababa=13,Oromia=4"). A buyer whose region is neither a code nor in this map fails
* locally rather than being filed with a guessed one.
*/
buyerRegionCodes: Record<string, string>;
/** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */
buyerWeredaCodes: Record<string, string>;
/**
* Buyer *zone* name → MoR City code, from `EIMS_BUYER_CITY_CODES` ("Kirkos=101"). `Company` has
* no dedicated city column — Zone is the closest match in EDR's own data. Optional, unlike
* Region/Wereda: MoR has never required City on a live buyer (confirmed — filing already
* succeeds with it null), so an unmapped zone falls back to null rather than failing the
* mapping.
*/
buyerCityCodes: Record<string, string>;
/**
* Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` +
* `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to
@@ -258,13 +228,6 @@ export default registerAs("eims", (): EimsConfig => {
paymentMode: process.env.EIMS_PAYMENT_MODE ?? "",
paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "",
unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "",
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
buyerCountryCodes: parseCodeMap(process.env.EIMS_BUYER_COUNTRY_CODES),
// Baked-in Ethiopia reference table first, env var entries win on a name collision — lets a
// deployment override or add to it without a redeploy. See ethiopia-geo-codes.ts.
buyerRegionCodes: { ...ETHIOPIA_REGION_CODES, ...parseCodeMap(process.env.EIMS_BUYER_REGION_CODES) },
buyerWeredaCodes: { ...ETHIOPIA_WOREDA_CODES, ...parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES) },
buyerCityCodes: { ...ETHIOPIA_ZONE_CODES, ...parseCodeMap(process.env.EIMS_BUYER_CITY_CODES) },
taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE),
taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE),
exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE),

View File

@@ -1,160 +0,0 @@
/**
* MoR EIMS region/zone/woreda codes, by name — the baked-in fallback under
* `EIMS_BUYER_REGION_CODES`/`EIMS_BUYER_WEREDA_CODES`/`EIMS_BUYER_CITY_CODES` (zone is the closest
* match to EIMS's "City", per `eims-invoice.mapper.ts`).
*
* Before this existed, every buyer from a not-yet-seen region/zone/woreda crashed EIMS filing until
* someone hunted down the code and added it to an env var by hand — happened three times in one
* afternoon (2026-08-17: Somali region, Fafan zone, Jigjiga woreda, even the Ethiopia country code
* itself were all unset). Ethiopia's administrative divisions are fixed, known, reference data, not
* something that should be maintained reactively per buyer. Source: `ethiopia_administrative_
* hierarchy_master.csv`, supplied 2026-08-17 — NOT exhaustive (a representative sample per region,
* not all ~1000 real woredas), extend as new gaps surface.
*
* The env vars stay wired in ahead of this table (see `eims.config.ts`) — for a quick correction
* without a redeploy, or a name spelled differently in a buyer's profile than in this table (already
* hit live: DB has zone "Fafen", this table's official spelling is "Fafan" — same zone, matching is
* case/space-insensitive but not spelling-tolerant, so the env var override is still how that buyer
* actually resolves; this table mainly helps the *next* buyer whose profile spelling matches).
*
* ponytail: region names are unique nationwide (only ~15), safe as a flat map. Zone and woreda names
* are not always unique across different regions (e.g. "North Shewa" is both an Amhara zone and an
* Oromia zone, different codes) — `Company` stores region/zone/woreda as three independent strings,
* no parent linkage, so a flat name lookup can't disambiguate. First occurrence in the source data
* wins on a collision. Only affects the optional `City` field (zone) — never blocks filing, unlike
* Region/Wereda. A correct fix needs `Company` to store a linked hierarchy, not just three strings;
* out of scope here. Upgrade path: key this by `${region}/${zone}` once that linkage exists.
*/
const ROWS: Array<[region: string, zone: string, woreda: string, regionCode: string, zoneCode: string, woredaCode: string]> = [
["Tigray", "Western Tigray", "Humera", "01", "01", "01"],
["Tigray", "Western Tigray", "Kafta Humera", "01", "01", "02"],
["Tigray", "Western Tigray", "Tsegede", "01", "01", "03"],
["Tigray", "North Western Tigray", "Shire Endaselassie", "01", "02", "01"],
["Tigray", "North Western Tigray", "Sheraro", "01", "02", "02"],
["Tigray", "Central Tigray", "Axum", "01", "03", "01"],
["Tigray", "Central Tigray", "Adwa", "01", "03", "02"],
["Tigray", "Eastern Tigray", "Adigrat", "01", "04", "01"],
["Tigray", "Southern Tigray", "Maychew", "01", "05", "01"],
["Tigray", "Mekelle Special Zone", "Mekelle City", "01", "06", "01"],
["Afar", "Awusi Rasu (Zone 1)", "Asayita", "02", "01", "01"],
["Afar", "Awusi Rasu (Zone 1)", "Semera-Logiya", "02", "01", "02"],
["Afar", "Kilbet Rasu (Zone 2)", "Abala", "02", "02", "01"],
["Afar", "Gabi Rasu (Zone 3)", "Awash Fentale", "02", "03", "01"],
["Afar", "Fantena Rasu (Zone 4)", "Yalo", "02", "04", "01"],
["Afar", "Hari Rasu (Zone 5)", "Telalak", "02", "05", "01"],
["Amhara", "North Gondar", "Debark", "03", "01", "01"],
["Amhara", "South Gondar", "Debre Tabor", "03", "02", "01"],
["Amhara", "North Wollo", "Woldiya", "03", "03", "01"],
["Amhara", "South Wollo", "Dessie Town", "03", "04", "01"],
["Amhara", "North Shewa", "Debre Berhan", "03", "05", "01"],
["Amhara", "East Gojjam", "Debre Markos", "03", "06", "01"],
["Amhara", "West Gojjam", "Finote Selam", "03", "07", "01"],
["Amhara", "Wag Hemra", "Sekota", "03", "08", "01"],
["Amhara", "Awi", "Injibara", "03", "09", "01"],
["Amhara", "Oromia Special Zone", "Kemise", "03", "10", "01"],
["Amhara", "Bahir Dar Special Zone", "Bahir Dar City", "03", "11", "01"],
["Amhara", "Gondar Special Zone", "Gondar City", "03", "12", "01"],
["Oromia", "North Shewa", "Fiche", "04", "01", "01"],
["Oromia", "South West Shewa", "Waliso", "04", "02", "01"],
["Oromia", "East Shewa", "Adama Town", "04", "03", "01"],
["Oromia", "East Shewa", "Bishoftu Town", "04", "03", "02"],
["Oromia", "West Shewa", "Ambo", "04", "04", "01"],
["Oromia", "Arsi", "Asella", "04", "05", "01"],
["Oromia", "West Arsi", "Shashemene", "04", "06", "01"],
["Oromia", "Bale", "Robe", "04", "07", "01"],
["Oromia", "East Hararghe", "Harar Outskirts", "04", "08", "01"],
["Oromia", "West Hararghe", "Chiro", "04", "09", "01"],
["Oromia", "Jimma", "Jimma Town", "04", "10", "01"],
["Oromia", "Illubabor", "Mettu", "04", "11", "01"],
["Oromia", "Buno Bedele", "Bedele", "04", "12", "01"],
["Oromia", "Welega (West)", "Gimbi", "04", "13", "01"],
["Oromia", "Welega (East)", "Nekemte", "04", "14", "01"],
["Oromia", "Horo Guduru Welega", "Shambu", "04", "15", "01"],
["Oromia", "Kelam Welega", "Dembidolo", "04", "16", "01"],
["Oromia", "Borena", "Yabelo", "04", "17", "01"],
["Oromia", "Guji", "Negele Borana", "04", "18", "01"],
["Oromia", "West Guji", "Bule Hora", "04", "19", "01"],
["Oromia", "East Bale", "Ginir", "04", "20", "01"],
["Oromia", "Sheger City", "Sululta", "04", "21", "01"],
["Somali", "Fafan", "Jijiga Woreda", "05", "01", "01"],
["Somali", "Fafan", "Jijiga Town", "05", "01", "02"],
["Somali", "Fafan", "Awbare", "05", "01", "03"],
["Somali", "Sitti", "Shinile", "05", "02", "01"],
["Somali", "Erer", "Fiq", "05", "03", "01"],
["Somali", "Jarar", "Degehabur", "05", "04", "01"],
["Somali", "Nogob", "Segeg", "05", "05", "01"],
["Somali", "Korahe", "Kebridehar", "05", "06", "01"],
["Somali", "Shabelle", "Gode", "05", "07", "01"],
["Somali", "Afder", "Afder Woreda", "05", "08", "01"],
["Somali", "Liben", "Filtu", "05", "09", "01"],
["Somali", "Dhawa", "Mubarak", "05", "10", "01"],
["Somali", "Dollo", "Warder", "05", "11", "01"],
["Benishangul-Gumuz", "Asosa", "Asosa Woreda", "06", "01", "01"],
["Benishangul-Gumuz", "Kamasashi", "Kamasashi Woreda", "06", "02", "01"],
["Benishangul-Gumuz", "Metekel", "Gilgel Beles", "06", "03", "01"],
["Southern Ethiopia", "Wolayta", "Sodo Zuria", "07", "01", "01"],
["Southern Ethiopia", "Wolayta", "Sodo Town", "07", "01", "02"],
["Southern Ethiopia", "Gamo", "Arba Minch Town", "07", "02", "01"],
["Southern Ethiopia", "Gofa", "Sawla", "07", "03", "01"],
["Southern Ethiopia", "Konso", "Konso Woreda", "07", "04", "01"],
["Southern Ethiopia", "South Omo", "Jinka", "07", "05", "01"],
["Gambela", "Anywaa", "Gambela Zuria", "08", "01", "01"],
["Gambela", "Nuer", "Lare", "08", "02", "01"],
["Gambela", "Majang", "Metu Zuria part", "08", "03", "01"],
["Harari", "Harar Hundanee", "Amir Nur Woreda", "09", "01", "01"],
["Harari", "Harar Hundanee", "Abadir Woreda", "09", "01", "02"],
["Addis Ababa", "Bole Sub-City", "Bole Woreda 01", "10", "01", "01"],
["Addis Ababa", "Kirkos Sub-City", "Kirkos Woreda 01", "10", "02", "01"],
["Addis Ababa", "Nifas Silk Lafto", "NSL Woreda 13", "10", "03", "13"],
["Addis Ababa", "Yeka Sub-City", "Yeka Woreda 01", "10", "04", "01"],
["Addis Ababa", "Arada Sub-City", "Arada Woreda 01", "10", "05", "01"],
["Dire Dawa", "Dire Dawa Urban", "Melka Jebdu", "11", "01", "01"],
["Dire Dawa", "Dire Dawa Rural", "Gurgura", "11", "02", "01"],
["Sidama", "Hawassa City Admin", "Hayek Chereka", "12", "01", "01"],
["Sidama", "Sidama Zuria", "Yirgalem Town", "12", "02", "01"],
["Sidama", "Sidama Zuria", "Aleta Wendo", "12", "02", "02"],
["Southwest Ethiopia", "Keffa", "Bonga Town", "13", "01", "01"],
["Southwest Ethiopia", "Sheka", "Mappi Zuria", "13", "02", "01"],
["Southwest Ethiopia", "Bench Sheko", "Mizan Aman", "13", "03", "01"],
["Central Ethiopia", "Gurage", "Wolkite", "14", "01", "01"],
["Central Ethiopia", "Hadiya", "Hosaina", "14", "02", "01"],
["Central Ethiopia", "Silte", "Worabe", "14", "03", "01"],
["Gedeo State", "Gedeo Zone", "Dilla Zuria", "15", "01", "01"],
["Gedeo State", "Gedeo Zone", "Yirgacheffe", "15", "01", "02"],
];
/** First occurrence wins on a name collision — see the class comment. */
const buildMap = (pick: (row: (typeof ROWS)[number]) => [string, string]): Record<string, string> => {
const map: Record<string, string> = {};
for (const row of ROWS) {
const [name, code] = pick(row);
if (!(name in map)) map[name] = code;
}
return map;
};
export const ETHIOPIA_REGION_CODES: Record<string, string> = buildMap((r) => [r[0], r[3]]);
/** Zone name → code. Fed into `buyerCityCodes` — EIMS's "City" is really the buyer's zone. */
export const ETHIOPIA_ZONE_CODES: Record<string, string> = buildMap((r) => [r[1], r[4]]);
export const ETHIOPIA_WOREDA_CODES: Record<string, string> = buildMap((r) => [r[2], r[5]]);
/**
* Buyer records commonly store just the bare Addis Ababa sub-city name ("Bole", "Arada") as their
* woreda, not the source CSV's specific example-woreda name ("Bole Woreda 01") — confirmed live
* 2026-08-17 across three different buyers before any of them actually got past this check. Since
* the CSV lists exactly one representative woreda per Addis sub-city, alias the bare name to that
* same code rather than wait on a fuller table.
*/
const ADDIS_SUBCITY_ALIASES: Array<[bareName: string, csvZoneName: string]> = [
["Bole", "Bole Sub-City"],
["Kirkos", "Kirkos Sub-City"],
["Nifas Silk Lafto", "Nifas Silk Lafto"],
// Matches EIMS_BUYER_WEREDA_CODES' own existing spelling in .env — same zone, different hyphenation.
["Nefas Silk-Lafto", "Nifas Silk Lafto"],
["Yeka", "Yeka Sub-City"],
["Arada", "Arada Sub-City"],
];
for (const [bareName, csvZoneName] of ADDIS_SUBCITY_ALIASES) {
const row = ROWS.find((r) => r[1] === csvZoneName);
if (row && !(bareName in ETHIOPIA_WOREDA_CODES)) ETHIOPIA_WOREDA_CODES[bareName] = row[5];
}

View File

@@ -0,0 +1,275 @@
import { MorLocationTuple } from "./mor-locations.data";
import {
MorGeoMappingError,
normalizeName,
resolveMorGeo,
tryResolveMorGeo,
} from "./mor-location.resolver";
/**
* Rows copied verbatim out of the Ministry sheet (`EIMS_COUNTRY_REGION_VW`), chosen for the traps
* the real data contains rather than for tidiness:
*
* - BABILE and KERSA each exist in two different zones with different LOCALITY_NOs — the reason a
* global name lookup is unsafe and the hierarchy is mandatory.
* - ILLUBABOR has BURE twice under the same zone with different LOCALITY_NOs (691 and 890, the
* second with the Ministry's own trailing space) — a genuine ambiguity that must never be
* silently resolved to the first row.
* - "Wal-Mera" and "Akaki woreda" carry the sheet's mixed casing and punctuation.
*/
const FIXTURE: MorLocationTuple[] = [
[70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 190, "JIJIGA"],
[70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 194, "BABILE"],
[70, "Ethiopia", 6, "SOMALI", 30, "SITI ZONE", 197, "DENBEL"],
[70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 495, "BABILE"],
[70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 482, "KERSA"],
[70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 503, "KERSA"],
[70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 691, "BURE"],
[70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 890, "BURE "],
[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"],
[253, "Djibouti", 1, "DJIBOUTI", 1, "DJIBOUTI VILLE", 1, "BALBALA"],
];
const JIJIGA = {
country: "Ethiopia",
region: "SOMALI",
zone: "FAAFAN ZONE",
woreda: "JIJIGA",
};
describe("normalizeName", () => {
it("collapses whitespace, trims, and compares case-insensitively", () => {
expect(normalizeName(" FAAFAN ZONE ")).toBe("FAAFAN ZONE");
expect(normalizeName("faafan zone")).toBe("FAAFAN ZONE");
expect(normalizeName(" FAAFAN ZONE ")).toBe(normalizeName("faafan zone"));
});
it("normalizes harmless punctuation and hyphen/space differences", () => {
expect(normalizeName("Wal-Mera")).toBe("WAL MERA");
expect(normalizeName("Wal Mera")).toBe("WAL MERA");
expect(normalizeName("ZONE 1 (AYSSAITA)")).toBe("ZONE 1 AYSSAITA");
expect(normalizeName("Ber'ano")).toBe("BERANO");
expect(normalizeName("KEAHORE/HADAT/")).toBe("KEAHORE HADAT");
});
it("keeps digits, which several MoR locality names depend on", () => {
expect(normalizeName(" woreda 10 ")).toBe("WOREDA 10");
expect(normalizeName("WOREDA 1")).not.toBe(normalizeName("WOREDA 10"));
});
});
describe("resolveMorGeo", () => {
it("resolves the exact MoR spelling to the Ministry's own codes", () => {
expect(resolveMorGeo(JIJIGA, FIXTURE)).toEqual({
Country: "70",
Region: "6",
City: "31",
Wereda: "190",
});
});
it("resolves the EDR/e-Trade spellings through the alias layer", () => {
expect(
resolveMorGeo(
{
country: "Ethiopia",
region: "Somali",
zone: "Fafen",
woreda: "Jigjiga",
},
FIXTURE,
),
).toEqual({ Country: "70", Region: "6", City: "31", Wereda: "190" });
});
it("is case-insensitive", () => {
expect(
resolveMorGeo(
{
country: "ethiopia",
region: "somali",
zone: "faafan zone",
woreda: "jijiga",
},
FIXTURE,
),
).toEqual({ Country: "70", Region: "6", City: "31", Wereda: "190" });
});
it("ignores leading, trailing and repeated whitespace on every level", () => {
expect(
resolveMorGeo(
{
country: " Ethiopia ",
region: " SOMALI ",
zone: " FAAFAN ZONE ",
woreda: "\tJIJIGA ",
},
FIXTURE,
),
).toEqual({ Country: "70", Region: "6", City: "31", Wereda: "190" });
});
it("treats a hyphen as a space, in either direction", () => {
const expected = { Country: "70", Region: "2", City: "86", Wereda: "976" };
const base = {
country: "Ethiopia",
region: "Oromia",
zone: "Finfine Vic Spec",
};
expect(resolveMorGeo({ ...base, woreda: "Wal-Mera" }, FIXTURE)).toEqual(expected);
expect(resolveMorGeo({ ...base, woreda: "wal mera" }, FIXTURE)).toEqual(expected);
});
it("matches a zone whose MoR label carries the ' ZONE' suffix EDR does not store", () => {
expect(resolveMorGeo({ ...JIJIGA, zone: "Faafan" }, FIXTURE).City).toBe("31");
expect(
resolveMorGeo(
{
country: "Ethiopia",
region: "Somali",
zone: "Siti",
woreda: "Denbel",
},
FIXTURE,
),
).toEqual({ Country: "70", Region: "6", City: "30", Wereda: "197" });
});
describe("a locality name that exists in more than one zone", () => {
it("picks BABILE by its full hierarchy, never by name alone", () => {
expect(resolveMorGeo({ ...JIJIGA, woreda: "BABILE" }, FIXTURE).Wereda).toBe("194");
expect(
resolveMorGeo(
{
country: "Ethiopia",
region: "OROMIA",
zone: "MISRAK HARARGE",
woreda: "BABILE",
},
FIXTURE,
).Wereda,
).toBe("495");
});
it("picks KERSA by its full hierarchy", () => {
const oromia = { country: "Ethiopia", region: "OROMIA" };
expect(
resolveMorGeo({ ...oromia, zone: "MISRAK HARARGE", woreda: "KERSA" }, FIXTURE).Wereda,
).toBe("482");
expect(
resolveMorGeo({ ...oromia, zone: "JIMMA ZONE", woreda: "KERSA" }, FIXTURE).Wereda,
).toBe("503");
});
it("does not let a locality leak across regions", () => {
// DENBEL exists under SOMALI/SITI ZONE only — asking for it under OROMIA must fail, not
// fall back to the nationwide match the old flat maps would have found.
expect(() =>
resolveMorGeo(
{
country: "Ethiopia",
region: "OROMIA",
zone: "MISRAK HARARGE",
woreda: "DENBEL",
},
FIXTURE,
),
).toThrow(/no MoR LOCALITY_DESC match/);
});
});
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/],
["unknown region", { ...JIJIGA, region: "Atlantis" }, /no MoR PARISH_NAME match/],
["unknown zone", { ...JIJIGA, zone: "Nowhere Zone" }, /no MoR CITY_NAME match/],
["unknown woreda", { ...JIJIGA, woreda: "Example" }, /no MoR LOCALITY_DESC match/],
];
it.each(cases)("%s fails with an actionable validation error", (_label, input, pattern) => {
expect(() => resolveMorGeo(input, FIXTURE)).toThrow(MorGeoMappingError);
expect(() => resolveMorGeo(input, FIXTURE)).toThrow(pattern);
});
it("names the offending address in the message so the company record can be corrected", () => {
expect(() => resolveMorGeo({ ...JIJIGA, woreda: "Example" }, FIXTURE)).toThrow(
/country="Ethiopia", region="SOMALI", zone="FAAFAN ZONE", woreda="Example"/,
);
});
it("refuses an ambiguous locality instead of taking the first row", () => {
const input = {
country: "Ethiopia",
region: "OROMIA",
zone: "ILLUBABOR",
woreda: "BURE",
};
expect(() => resolveMorGeo(input, FIXTURE)).toThrow(MorGeoMappingError);
expect(() => resolveMorGeo(input, FIXTURE)).toThrow(/ambiguous/);
// Both colliding codes are named, and neither is silently selected.
expect(() => resolveMorGeo(input, FIXTURE)).toThrow(/691, 890/);
expect(tryResolveMorGeo(input, FIXTURE)).toBeNull();
});
it("fails loudly when the MoR master has not been generated yet", () => {
expect(() => resolveMorGeo(JIJIGA, [])).toThrow(/MoR location master is empty/);
});
});
it("reproduces MoR's numeric values unchanged, as strings", () => {
const codes = resolveMorGeo(JIJIGA, FIXTURE);
expect(codes).toEqual({
Country: "70",
Region: "6",
City: "31",
Wereda: "190",
});
for (const value of Object.values(codes)) {
expect(typeof value).toBe("string");
expect(value).toMatch(/^[0-9]+$/);
}
// The source row is the only origin of every code — no renumbering, no derivation.
const [countryNo, , parishNo, , cityNo, , localityNo] = FIXTURE[0];
expect(codes).toEqual({
Country: String(countryNo),
Region: String(parishNo),
City: String(cityNo),
Wereda: String(localityNo),
});
});
it("never emits an Open Admin Data ETxx identifier", () => {
for (const value of Object.values(resolveMorGeo(JIJIGA, FIXTURE))) {
expect(value).not.toMatch(/^ET/i);
}
});
it("treats a blank country as domestic, matching the column default", () => {
expect(resolveMorGeo({ ...JIJIGA, country: "" }, FIXTURE).Country).toBe("70");
expect(resolveMorGeo({ ...JIJIGA, country: null }, FIXTURE).Country).toBe("70");
});
it("resolves a named foreign country rather than defaulting it to Ethiopia", () => {
expect(
resolveMorGeo(
{
country: "Djibouti",
region: "DJIBOUTI",
zone: "DJIBOUTI VILLE",
woreda: "BALBALA",
},
FIXTURE,
),
).toEqual({ Country: "253", Region: "1", City: "1", Wereda: "1" });
});
it("accepts a company record that already holds a MoR code, but only a real one", () => {
expect(resolveMorGeo({ ...JIJIGA, region: "6" }, FIXTURE).Region).toBe("6");
expect(() => resolveMorGeo({ ...JIJIGA, region: "999" }, FIXTURE)).toThrow(
/no MoR PARISH_NAME match/,
);
});
});

View File

@@ -0,0 +1,255 @@
import { BadRequestException } from "@nestjs/common";
import { MOR_LOCATIONS, MorLocationTuple } from "./mor-locations.data";
/**
* Resolves an EDR company address to the Ministry of Revenues' own EIMS location codes, using the
* MoR location master (`EIMS_COUNTRY_REGION_VW`) shipped in `mor-locations.data.ts`.
*
* MoR's field names do not line up with either EDR's or generic Ethiopian administrative datasets,
* so the mapping is fixed by the Ministry sheet, not by interpretation:
*
* Company.country -> COUNTRY_NAME -> COUNTRY_NO -> BuyerDetails.Country
* Company.region -> PARISH_NAME -> PARISH_NO -> BuyerDetails.Region
* Company.zone -> CITY_NAME -> CITY_NO -> BuyerDetails.City
* Company.woreda -> LOCALITY_DESC -> LOCALITY_NO -> BuyerDetails.Wereda
*
* This replaces the previous `EIMS_BUYER_*_CODES` environment maps and the `ethiopia-geo-codes.ts`
* table they layered over. Both invented their codes (sequential "01".."15" per region, from a
* generic administrative CSV) and both looked names up **globally**, which cannot be correct:
* KERSA, GORO, BABILE and BURE each occur in several different zones with different LOCALITY_NOs.
* A global name lookup silently picked the first, i.e. filed a real invoice against whichever tax
* jurisdiction happened to sort first. Resolution here is strictly hierarchical — each level is
* searched only within the rows its parent already selected.
*
* Open Admin Data identifiers (`ET14`, `ET0407`, …) are unrelated to this code system and must
* never appear in an EIMS payload; nothing in this module can emit one, since every returned value
* comes from a numeric column of the Ministry sheet.
*/
export interface MorGeoCodes {
/** COUNTRY_NO as a string — `BuyerDetails.Country`. */
Country: string;
/** PARISH_NO as a string — `BuyerDetails.Region`. */
Region: string;
/** CITY_NO as a string — `BuyerDetails.City`. MoR calls the zone level "City". */
City: string;
/** LOCALITY_NO as a string — `BuyerDetails.Wereda`. */
Wereda: string;
}
export interface MorAddressInput {
country?: string | null;
region?: string | null;
zone?: string | null;
woreda?: string | null;
}
type Level = "country" | "region" | "zone" | "woreda";
/** Which tuple slots hold the name and the code at each level. */
const SLOTS: Record<Level, { name: 1 | 3 | 5 | 7; no: 0 | 2 | 4 | 6; column: string }> = {
country: { name: 1, no: 0, column: "COUNTRY_NAME" },
region: { name: 3, no: 2, column: "PARISH_NAME" },
zone: { name: 5, no: 4, column: "CITY_NAME" },
woreda: { name: 7, no: 6, column: "LOCALITY_DESC" },
};
/**
* One normalized form for both sides of every comparison. Deliberately conservative: it removes
* differences that cannot change which jurisdiction is meant (case, stray and repeated whitespace,
* hyphen/slash/parenthesis/apostrophe punctuation, combining accents) and nothing else. There is
* no fuzzy or edit-distance matching anywhere in this module — a near-miss must fail loudly rather
* than file an invoice against a neighbouring woreda.
*
* " FAAFAN ZONE " -> "FAAFAN ZONE"
* "Wal-Mera" -> "WAL MERA"
* "Ber'ano" -> "BERANO"
* "ZONE 1 (AYSSAITA)"-> "ZONE 1 AYSSAITA"
*/
const normalizeCache = new Map<string, string>();
export function normalizeName(value: string | null | undefined): string {
const raw = value ?? "";
const hit = normalizeCache.get(raw);
if (hit !== undefined) return hit;
const normalized = raw
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toUpperCase()
.replace(/['\u2018\u2019`]/g, "")
.replace(/[^A-Z0-9]+/g, " ")
.trim();
normalizeCache.set(raw, normalized);
return normalized;
}
/**
* Reviewed spelling differences between what EDR/e-Trade store and what the Ministry sheet calls
* the same place. Every entry is scoped to the administrative level it applies to, and to its
* parent where the name is not unique nationwide — so an alias can never reach across into another
* region's jurisdiction. `from`/`to` are compared normalized, so casing and spacing here are
* cosmetic.
*
* Add an entry only after confirming the two names are the same place in the Ministry sheet. This
* is the only sanctioned place for spelling compatibility; `mor-locations.data.ts` stays verbatim.
*/
interface MorAlias {
level: Exclude<Level, "country">;
/** Parent scope, normalized-compared. Omit a level to leave the alias unscoped at that level. */
region?: string;
zone?: string;
from: string;
to: string;
}
const ALIASES: MorAlias[] = [
// e-Trade and the customer portal both spell the Somali zone "Fafen"; MoR spells it "FAAFAN
// ZONE". Confirmed same zone (CITY_NO 31) — this is the buyer that first exposed the whole
// fabricated-code problem.
{ level: "zone", region: "SOMALI", from: "Fafen", to: "FAAFAN ZONE" },
// MoR's own capital of that zone is "JIJIGA"; every other source spells it "Jigjiga".
{
level: "woreda",
region: "SOMALI",
zone: "FAAFAN ZONE",
from: "Jigjiga",
to: "JIJIGA",
},
];
/**
* MoR suffixes many zone labels with " ZONE" ("JIMMA ZONE", "FAAFAN ZONE", "SITI ZONE") while EDR
* stores the bare name. Retrying the suffixed spelling is an exact match against a second candidate
* string, scoped to the already-resolved region — not fuzzy matching — and it removes a long tail
* of otherwise hand-maintained aliases. Applied to the zone level only: locality suffixes
* ("WOREDA", "TOWN ADMINISTRATION") are not mechanical and could select a different place.
*/
const zoneSuffixCandidates = (normalized: string): string[] =>
normalized.endsWith(" ZONE") ? [] : [`${normalized} ZONE`];
export class MorGeoMappingError extends BadRequestException {
constructor(code: "EIMS_GEO_MAPPING_FAILED" | "EIMS_GEO_AMBIGUOUS", message: string) {
super({ code, message });
}
}
/** Renders the address being resolved for an error message. No customer-identifying data. */
const describe = (input: MorAddressInput): string =>
`country="${input.country ?? ""}", region="${input.region ?? ""}", ` +
`zone="${input.zone ?? ""}", woreda="${input.woreda ?? ""}"`;
function matchLevel(
rows: MorLocationTuple[],
level: Level,
raw: string | null | undefined,
parents: { region?: string; zone?: string },
input: MorAddressInput,
): { no: number; rows: MorLocationTuple[] } {
const { name: nameSlot, no: noSlot, column } = SLOTS[level];
const wanted = normalizeName(raw);
const candidates: string[] = [];
if (wanted) {
candidates.push(wanted);
for (const alias of ALIASES) {
if (alias.level !== level) continue;
if (alias.region && normalizeName(alias.region) !== parents.region) continue;
if (alias.zone && normalizeName(alias.zone) !== parents.zone) continue;
if (normalizeName(alias.from) === wanted) candidates.push(normalizeName(alias.to));
}
if (level === "zone") candidates.push(...zoneSuffixCandidates(wanted));
}
let matched: MorLocationTuple[] = [];
for (const candidate of candidates) {
matched = rows.filter((row) => normalizeName(row[nameSlot] as string) === candidate);
if (matched.length > 0) break;
}
// A company record that already holds the MoR code itself resolves too — but only when that code
// genuinely exists at this level under this parent. An unvalidated numeric pass-through is how a
// wrong code reaches MoR without anything noticing.
if (matched.length === 0 && /^[0-9]{1,6}$/.test((raw ?? "").trim())) {
const asCode = Number((raw ?? "").trim());
matched = rows.filter((row) => row[noSlot] === asCode);
}
if (matched.length === 0) {
throw new MorGeoMappingError(
"EIMS_GEO_MAPPING_FAILED",
`EIMS geographic mapping failed: no MoR ${column} match for ${describe(input)}.`,
);
}
const distinct = [...new Set(matched.map((row) => row[noSlot] as number))];
if (distinct.length > 1) {
throw new MorGeoMappingError(
"EIMS_GEO_AMBIGUOUS",
`EIMS geographic mapping is ambiguous: MoR ${column} "${(raw ?? "").trim()}" matches ` +
`${distinct.length} different codes (${distinct.sort((a, b) => a - b).join(", ")}) for ` +
`${describe(input)}. Correct the company address or the MoR reference data; an ambiguous ` +
"location is never filed.",
);
}
return { no: distinct[0], rows: matched };
}
/**
* Resolves the full hierarchy, or throws a `BadRequestException` naming the level that failed.
*
* Never guesses and never returns a partial result: an unknown or ambiguous location must stop the
* filing here, locally, before any MoR request and before an EIMS counter is consumed.
*/
export function resolveMorGeo(
input: MorAddressInput,
rows: MorLocationTuple[] = MOR_LOCATIONS,
): MorGeoCodes {
if (rows.length === 0) {
throw new MorGeoMappingError(
"EIMS_GEO_MAPPING_FAILED",
"EIMS geographic mapping failed: the MoR location master is empty. Generate it with " +
"`pnpm --filter @edr/freight-api eims:import-locations <workbook.xlsx>`.",
);
}
// `companies.country` defaults to 'Ethiopia' and is often left blank on older rows; blank means
// domestic here, exactly as the column default says. A *named* foreign country is resolved like
// any other and fails if MoR does not list it — it is never quietly filed as Ethiopia.
const country = (input.country ?? "").trim() || "Ethiopia";
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),
};
}
/** Non-throwing variant for callers that already have a working fallback (the seller identity). */
export function tryResolveMorGeo(
input: MorAddressInput,
rows: MorLocationTuple[] = MOR_LOCATIONS,
): MorGeoCodes | null {
try {
return resolveMorGeo(input, rows);
} catch {
return null;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -122,6 +122,8 @@ export class ContractDocumentViewModelBuilder {
contract.customsClearingEnabled,
// Bulk templates are keyed by the contract's cargo type.
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
// Ethiopian-customs-only service types resolve to the Ethiopian variant.
contract.serviceType?.includesEthiopianCustomsOnly,
);
dynamicTemplate = dynamicSource
? {

View File

@@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Adds `reference` to freight.audit_logs — the human identifier of the entity
* the action touched (booking reference, schedule number, train number, …),
* resolved at write time by the audit interceptor. `resource_id` stays the
* machine id; this column is what staff actually type into the search box.
*
* Production safety:
* - `ADD COLUMN ... NOT NULL DEFAULT ''` is metadata-only on Postgres 11+:
* no table rewrite, no long lock, existing rows read '' without being
* touched. Rows written before this migration keep '' permanently —
* capture starts from deploy, by design (no backfill).
* - Everything is IF NOT EXISTS so a hand-patched database converges
* instead of failing the deploy.
* - No existing column is altered and nothing is dropped: zero data-loss
* surface.
*
* The index is an expression index on upper(reference) with
* text_pattern_ops so the search endpoint's case-insensitive prefix match
* (`upper(reference) LIKE upper($1) || '%'`) is indexed. '' rows are
* excluded to keep it small — they are never searched for.
*/
export class AuditLogReference3690000000000 implements MigrationInterface {
name = 'AuditLogReference3690000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.audit_logs
ADD COLUMN IF NOT EXISTS reference varchar(64) NOT NULL DEFAULT ''
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_audit_logs_reference_upper
ON freight.audit_logs (upper(reference) text_pattern_ops)
WHERE reference <> ''
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Down discards every captured reference — acceptable only because down
// migrations are never run against production here.
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_audit_logs_reference_upper`);
await queryRunner.query(`ALTER TABLE freight.audit_logs DROP COLUMN IF EXISTS reference`);
}
}

View File

@@ -0,0 +1,41 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Loading and unloading times, on the stop that already records the train
* standing at a station.
*
* The OCC report publishes, per train, "total loading and unloading time" and
* the "other activity" left over from the station stay. Nothing recorded when
* handling started or ended — the July 2026 seed had to write the figure into
* a checkpoint's note — so the staying-time report could only ever publish the
* whole stay.
*
* These four go on `train_checkpoint_events` rather than a table of their own:
* a stop is already one row there, keyed (schedule, sequence_no), and the
* arrival row is the one the staying-time report builds a stay from. All four
* are nullable — a stop where nobody logged the handling still reports its
* staying time, with the handling columns empty rather than zero.
*/
export class CheckpointHandlingTimes3690000000000 implements MigrationInterface {
name = "CheckpointHandlingTimes3690000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_checkpoint_events
ADD COLUMN IF NOT EXISTS unloading_started_at timestamptz,
ADD COLUMN IF NOT EXISTS unloading_completed_at timestamptz,
ADD COLUMN IF NOT EXISTS loading_started_at timestamptz,
ADD COLUMN IF NOT EXISTS loading_completed_at timestamptz;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_checkpoint_events
DROP COLUMN IF EXISTS unloading_started_at,
DROP COLUMN IF EXISTS unloading_completed_at,
DROP COLUMN IF EXISTS loading_started_at,
DROP COLUMN IF EXISTS loading_completed_at;
`);
}
}

View File

@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Standard loading-and-unloading time, so the handling figure can be reported
* the way the OCC scorecard reports it — hours against a target, with a rate.
*
* Nullable with NO default, unlike every other column in this table. The
* reporting spec publishes standards for a station stay (10h / 13h) and for a
* turn-around cycle (65 / 88 / 96) but none for handling, so there is no
* honest figure to seed. Until a planner enters one in Operating standards the
* rate reads empty rather than judging trains against an invented number.
*/
export class HandlingStandards3700000000000 implements MigrationInterface {
name = "HandlingStandards3700000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.operations_standards
ADD COLUMN IF NOT EXISTS handling_standard_hours_container numeric(6,2),
ADD COLUMN IF NOT EXISTS handling_standard_hours_bulk numeric(6,2);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.operations_standards
DROP COLUMN IF EXISTS handling_standard_hours_container,
DROP COLUMN IF EXISTS handling_standard_hours_bulk;
`);
}
}

View File

@@ -0,0 +1,30 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Adds the customs clearing agent's contact details to freight.bookings.
*
* The agent moved from the contract to the booking: on a without-customs
* service the customer now names their agent (name, email, phone) when
* completing each booking, instead of once at contract creation. The existing
* `customs_clearing_agent` column keeps the name; these two columns add the
* contact info. Nullable — customs-bundled and legacy bookings have none.
*/
export class BookingClearingAgentContact3710000000000 implements MigrationInterface {
name = 'BookingClearingAgentContact3710000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS customs_clearing_agent_email varchar(200),
ADD COLUMN IF NOT EXISTS customs_clearing_agent_phone varchar(50)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS customs_clearing_agent_email,
DROP COLUMN IF EXISTS customs_clearing_agent_phone
`);
}
}

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-station loading/unloading time windows on a schedule, operator-clicked:
* { [yardId]: { loading?: { startedAt, endedAt, startedByUserId, endedByUserId },
* unloading?: { same } } }
* Booking load/unload is gated on the matching window having been started.
*/
export class ScheduleStationWorkLogs3720000000000 implements MigrationInterface {
name = 'ScheduleStationWorkLogs3720000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS station_work_logs jsonb
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS station_work_logs
`);
}
}

View File

@@ -0,0 +1,74 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Approval gate for detaching a wagon (or sending it to maintenance) from a
* train whose run is already SCHEDULED.
*
* Before scheduling, the consist is the builder's to edit. After scheduling,
* pulling a wagon changes a departure customers booked against, so it becomes
* a two-person action: one staffer files a request with a reason, another
* staffer (with trains:approve_wagon_detach) approves it — approval executes
* the detach on the spot. Rows are never deleted; decided rows are the audit
* trail of who asked, who decided, and why.
*
* One PENDING row per (train, wagon) at a time — a second request while one is
* undecided is a coordination failure, not a workflow (partial unique index).
*/
export class WagonDetachRequests3730000000000 implements MigrationInterface {
name = 'WagonDetachRequests3730000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$ BEGIN
CREATE TYPE freight.wagon_detach_requests_status_enum
AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
EXCEPTION WHEN duplicate_object THEN NULL; END $$
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.wagon_detach_requests (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
train_id uuid NOT NULL REFERENCES freight.trains (id),
wagon_id uuid NOT NULL REFERENCES freight.wagons (id),
-- Snapshot: the audit trail must still read correctly after the wagon
-- is renumbered or deleted.
wagon_number varchar(50) NOT NULL,
action varchar(20) NOT NULL,
reason varchar(500) NOT NULL,
status freight.wagon_detach_requests_status_enum NOT NULL DEFAULT 'PENDING',
-- Who asked and who decided. Both recorded: the point of the gate is
-- that they are different people.
requested_by uuid,
decided_by uuid,
decided_at timestamptz,
decision_note varchar(500),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wagon_detach_requests_train
ON freight.wagon_detach_requests (train_id)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wagon_detach_requests_train_status
ON freight.wagon_detach_requests (train_id, status)
`);
// The workflow invariant, enforced where it cannot race: at most one
// undecided request per wagon per train.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_wagon_detach_requests_one_pending
ON freight.wagon_detach_requests (train_id, wagon_id)
WHERE status = 'PENDING' AND deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_detach_requests`);
await queryRunner.query(`DROP TYPE IF EXISTS freight.wagon_detach_requests_status_enum`);
}
}

View File

@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* NUMBER_OF_WAGONS cargo unit: the customer books a wagon COUNT alongside the
* bulk weight. `bulk_requested_wagons` drives allocation and PER_WAGON pricing;
* `bulk_item_count` is the optional informational item count entered with it.
* Nullable — every other cargo unit leaves both empty.
*/
export class BookingBulkRequestedWagons3740000000000 implements MigrationInterface {
name = 'BookingBulkRequestedWagons3740000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS bulk_requested_wagons int,
ADD COLUMN IF NOT EXISTS bulk_item_count int
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS bulk_requested_wagons,
DROP COLUMN IF EXISTS bulk_item_count
`);
}
}

View File

@@ -0,0 +1,118 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults';
/**
* Third customs-clearing option on contract templates: Ethiopian-customs-only
* (the Service Provider clears the Ethiopian side only, Djibouti stays with
* the Client), matching service types with includes_ethiopian_customs_only.
*
* - ethiopian_customs_only column on contract_templates (bulk variant flag;
* the seeded container variants carry it in the code suffix instead, like
* the existing _CUSTOMS/_NO_CUSTOMS pair).
* - The bulk unique index and intercity check widen to the new flag.
* - Seeds the two new system container templates from the defaults pack.
*/
const SEEDED_CODES = [
'IMPORT_CONTAINER_ETHIOPIAN_CUSTOMS',
'EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS',
] as const;
export class EthiopianCustomsContractTemplates3750000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.contract_templates
ADD COLUMN IF NOT EXISTS ethiopian_customs_only boolean
`);
await queryRunner.query(
`DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_dir_customs`,
);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_dir_customs
ON freight.contract_templates
(cargo_type_id, trade_direction,
COALESCE(with_customs, false), COALESCE(ethiopian_customs_only, false))
WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.contract_templates
DROP CONSTRAINT IF EXISTS ck_bulk_intercity_no_customs
`);
await queryRunner.query(`
ALTER TABLE freight.contract_templates
ADD CONSTRAINT ck_bulk_intercity_no_customs CHECK (
cargo_type_id IS NULL
OR (
trade_direction IN ('IMPORT', 'EXPORT', 'INTERCITY')
AND (trade_direction = 'INTERCITY') = (with_customs IS NULL)
AND (ethiopian_customs_only IS NOT TRUE OR with_customs IS TRUE)
)
)
`);
for (const code of SEEDED_CODES) {
const seed = CONTRACT_TEMPLATE_DEFAULTS.find((t) => t.code === code);
if (!seed) throw new Error(`Missing contract template default for ${code}`);
await queryRunner.query(
`INSERT INTO freight.contract_templates
(id, code, name, description, document_title, whereas_clauses, articles,
is_active, is_system, created_at, updated_at)
SELECT gen_random_uuid(), $1::varchar, $2, $3, $4, $5::jsonb, $6::jsonb,
true, true, now(), now()
WHERE NOT EXISTS (
SELECT 1 FROM freight.contract_templates
WHERE code = $1::varchar AND deleted_at IS NULL
)`,
[
seed.code,
seed.name,
seed.description,
seed.documentTitle,
JSON.stringify(seed.whereasClauses),
JSON.stringify(
seed.articles.map((article, index) => ({ ...article, order: index + 1 })),
),
],
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM freight.contract_templates WHERE code = ANY($1) AND is_system = true`,
[[...SEEDED_CODES]],
);
await queryRunner.query(`
ALTER TABLE freight.contract_templates
DROP CONSTRAINT IF EXISTS ck_bulk_intercity_no_customs
`);
await queryRunner.query(`
ALTER TABLE freight.contract_templates
ADD CONSTRAINT ck_bulk_intercity_no_customs CHECK (
cargo_type_id IS NULL
OR (
trade_direction IN ('IMPORT', 'EXPORT', 'INTERCITY')
AND (trade_direction = 'INTERCITY') = (with_customs IS NULL)
)
)
`);
await queryRunner.query(
`DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_dir_customs`,
);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_dir_customs
ON freight.contract_templates
(cargo_type_id, trade_direction, COALESCE(with_customs, false))
WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.contract_templates
DROP COLUMN IF EXISTS ethiopian_customs_only
`);
}
}

View File

@@ -12,7 +12,7 @@
* humanized handler name where a route has none.
*
* Excludes the AI Assist and Account entities.
* Generated from the controllers under src/ — 517 endpoints.
* Generated from the controllers under src/ — 528 endpoints.
*/
/** [title, method, entity] for one auditable route. */
export type AuditEndpointMeta = readonly [title: string, method: string, entity: string];
@@ -38,6 +38,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/bookings/:id/clearance/draft-declaration": ["GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review", "POST", "Booking"],
"POST /api/bookings/:id/clearance/draft-declaration/accept": ["Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia", "POST", "Booking"],
"POST /api/bookings/:id/clearance/draft-declaration/change": ["Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)", "POST", "Booking"],
"POST /api/bookings/:id/clearance/draft-declaration/skip": ["GL ET skips the draft-declaration round: no estimate is sent to the customer, the real declaration is filed directly and duty & tax passes by default", "POST", "Booking"],
"POST /api/bookings/:id/clearance/duty": ["GL ET sets duty/tax on booking with notice attachment", "POST", "Booking"],
"POST /api/bookings/:id/clearance/duty-slip": ["Customer uploads duty/tax payment slip on booking", "POST", "Booking"],
"POST /api/bookings/:id/clearance/export-release": ["Confirm Booking Export Release", "POST", "Booking"],
@@ -51,7 +52,12 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/bookings/:id/clearance/charges/port-document": ["GL Djibouti uploads the port-charges document", "POST", "Booking"],
"PATCH /api/bookings/:id/clearance/charges/:chargeId/bill": ["GL Ethiopia sets or revises a clearance charge's amount + currency", "PATCH", "Booking"],
"POST /api/bookings/:id/clearance/charges/:chargeId/send": ["GL Ethiopia issues the clearance charge invoice to the customer", "POST", "Booking"],
"POST /api/bookings/:id/clearance/charges/:chargeId/accept": ["Customer accepts a proposed clearance charge — issues the payable invoice and locks the charge", "POST", "Booking"],
"POST /api/bookings/:id/clearance/charges/:chargeId/reject": ["Customer rejects a proposed clearance charge with a reason — GL Ethiopia revises and re-sends", "POST", "Booking"],
"POST /api/bookings/:id/clearance/charges/miscellaneous": ["GL Ethiopia creates the miscellaneous clearance charge", "POST", "Booking"],
"POST /api/bookings/:id/additional-charges": ["Finance raises a new additional charge — draft, or send to the customer immediately", "POST", "Booking"],
"POST /api/bookings/:id/additional-charges/:chargeId/send": ["Issue the draft charge's payable invoice and notify the customer", "POST", "Booking"],
"POST /api/bookings/:id/additional-charges/:chargeId/cancel": ["Withdraw a draft or unpaid additional charge", "POST", "Booking"],
"POST /api/bookings/:id/clearance/ro-amendment": ["Request Booking RO Amendment", "POST", "Booking"],
"POST /api/bookings/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Booking"],
"POST /api/bookings/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration", "POST", "Booking"],
@@ -73,7 +79,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/bookings/:id/documents": ["Upload documents for a booking (DRAFT only)", "POST", "Booking"],
"PATCH /api/bookings/:id/export-handover-mode": ["Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first", "PATCH", "Booking"],
"POST /api/bookings/:id/generate-grn": ["Generate a GRN over the received containers (all received, or a subset) — one GRN per batch", "POST", "Booking"],
"POST /api/bookings/:id/generate-price": ["Generate price preview (DRAFT or CHANGES_REQUESTED)", "POST", "Booking"],
// "POST /api/bookings/:id/generate-price": ["Generate price preview (DRAFT or CHANGES_REQUESTED)", "POST", "Booking"],
"POST /api/bookings/:id/government-expedite": ["Expedite government booking to PAID / ELIGIBLE for scheduling", "POST", "Booking"],
"POST /api/bookings/:id/marketing/approve": ["Staff contract signature and fully execute (use contract/sign STAFF preferred)", "POST", "Booking"],
"POST /api/bookings/:id/operation/review": ["Operations reviews an operation request: ACCEPT (→ batch pool),", "POST", "Booking"],
@@ -85,7 +91,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/bookings/:id/staff/request-changes": ["Staff return booking for customer updates", "POST", "Booking"],
"POST /api/bookings/:id/submit": ["Customer submit booking", "POST", "Booking"],
"POST /api/bookings/:id/wagon-cancellations": ["Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles", "POST", "Booking"],
"POST /api/bookings/:id/wagon-cancellations/preview": ["Preview the fee/credit of a partial wagon cancellation (no writes)", "POST", "Booking"],
// "POST /api/bookings/:id/wagon-cancellations/preview": ["Preview the fee/credit of a partial wagon cancellation (no writes)", "POST", "Booking"],
"POST /api/bookings/wagon-cancellations/:cancellationId/rebook": ["Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)", "POST", "Booking"],
"POST /api/bookings/wagon-cancellations/:cancellationId/withdraw": ["Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)", "POST", "Booking"],
"POST /api/bookings/consolidation-approvals/:approvalId/approve": ["Approve a shared wagon: both bookings leave the gate and continue to Operations together.", "POST", "Booking"],
@@ -207,7 +213,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/contracts/:id/staff/request-changes": ["Staff return contract for customer updates", "POST", "Contract"],
"POST /api/contracts/:id/submit": ["Customer submit contract (freezes contract_rate_snapshots)", "POST", "Contract"],
"POST /api/contracts/:id/suspend": ["Staff freeze a signed contract (reversible, any post-signature step)", "POST", "Contract"],
"POST /api/contracts/:id/validate-shipment": ["Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created)", "POST", "Contract"],
// "POST /api/contracts/:id/validate-shipment": ["Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created)", "POST", "Contract"],
"POST /api/contracts/booking-requests/:reqId/accept": ["GL marks a shipment request accepted + links the created booking", "POST", "Contract"],
"POST /api/contracts/booking-requests/:reqId/cancel": ["Customer cancels their own pending shipment request", "POST", "Contract"],
"POST /api/contracts/booking-requests/:reqId/reject": ["GL rejects a shipment request", "POST", "Contract"],
@@ -240,7 +246,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"PUT /api/contract-templates/:code/articles": ["Replace the full ordered article list (used for reorder)", "PUT", "Contract Template"],
"PATCH /api/contract-templates/:code/articles/:articleId": ["Update an article's title or body", "PATCH", "Contract Template"],
"DELETE /api/contract-templates/:code/articles/:articleId": ["Remove an article from the template", "DELETE", "Contract Template"],
"POST /api/contract-templates/:code/preview": ["Render an HTML preview of the template against mock contract data", "POST", "Contract Template"],
// "POST /api/contract-templates/:code/preview": ["Render an HTML preview of the template against mock contract data", "POST", "Contract Template"],
// Driver
"POST /api/drivers": ["Create a new driver", "POST", "Driver"],
@@ -266,6 +272,8 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/invoices/:id/eims/receipt/sales": ["Register a sales receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"],
"POST /api/invoices/:id/eims/receipt/withholding": ["Register a withholding receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"],
"POST /api/invoices/eims/bulk-cancel": ["Cancel multiple invoices", "POST", "EIMS Invoice"],
"POST /api/invoices/eims/bulk-register": ["Submit multiple invoices to MoR EIMS in one call. Asynchronous — this only confirms MoR", "POST", "EIMS Invoice"],
"POST /api/eims/webhook/bulk-register": ["EIMS bulk-register webhook callback (MoR reports per-invoice results)", "POST", "EIMS Invoice"],
// Exchange Setting
"PATCH /api/exchange-settings": ["Set the USD→ETB fallback by hand (used only while CBE is unreachable)", "PATCH", "Exchange Setting"],
@@ -373,6 +381,14 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"PATCH /api/notifications/:id/read": ["Mark one of my notifications as read", "PATCH", "Notification Inbox"],
"POST /api/notifications/read-all": ["Mark all my notifications as read", "POST", "Notification Inbox"],
// Operations Standard
"PATCH /api/operations-standards": ["Change one or more operating standards", "PATCH", "Operations Standard"],
// Operations Target
"POST /api/operations-targets": ["Create a planned target", "POST", "Operations Target"],
"PATCH /api/operations-targets/:id": ["Update a planned target", "PATCH", "Operations Target"],
"DELETE /api/operations-targets/:id": ["Soft-delete a planned target", "DELETE", "Operations Target"],
// Organization User
"PUT /api/backoffice/organizations/:orgId/employee-users/:userId/roles": ["Replace org-scoped roles assigned to an employee user", "PUT", "Organization User"],
"POST /api/backoffice/organizations/:orgId/users": ["Create an organization user without assigning positions", "POST", "Organization User"],
@@ -445,7 +461,8 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
// Two controllers register this same path; Nest serves whichever module loads first.
"POST /api/train-scheduling/schedules/:id/maintenance": ["Reschedule train for maintenance (new departure + rebalance)", "POST", "Schedule"],
"POST /api/train-scheduling/schedules/:id/reschedule/execute": ["Execute a confirmed reschedule plan", "POST", "Schedule"],
"POST /api/train-scheduling/schedules/:id/reschedule/preview": ["Preview reschedule / government preempt plan", "POST", "Schedule"],
"POST /api/train-scheduling/schedules/:id/reschedule/maintenance": ["Reschedule train for maintenance (new departure + rebalance)", "POST", "Schedule"],
// "POST /api/train-scheduling/schedules/:id/reschedule/preview": ["Preview reschedule / government preempt plan", "POST", "Schedule"],
// Service Type
"POST /api/service-types": ["Create a service type", "POST", "Service Type"],
@@ -463,7 +480,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
// Shipping Line Booking
"POST /api/shipping-line-bookings/initiate": ["Initiate a bare booking (no contract). Starts at AWAITING_DOCUMENTS so the shipping line can upload its documents for Operations to approve.", "POST", "Shipping Line Booking"],
"POST /api/shipping-line-bookings/:id/cancel": ["Cancel one of the signed-in shipping line's own bookings. Allowed only before the booking is priced.", "POST", "Shipping Line Booking"],
"POST /api/shipping-line-bookings/:id/price-preview": ["Authoritative price quote for the completion payload — same compute as /complete, saved as the booking's breakdown + rate snapshots (refreshed on every re-preview). Persists nothing else.", "POST", "Shipping Line Booking"],
// "POST /api/shipping-line-bookings/:id/price-preview": ["Authoritative price quote for the completion payload — same compute as /complete, saved as the booking's breakdown + rate snapshots (refreshed on every re-preview). Persists nothing else.", "POST", "Shipping Line Booking"],
"POST /api/shipping-line-bookings/:id/complete": ["Complete an approved (CLEARANCE_READY) booking: cargo + binding shipment day.", "POST", "Shipping Line Booking"],
// Shipping Line Credit
@@ -510,6 +527,8 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"PATCH /api/train-builder/:id/details": ["Edit the train's name and fixed import/export run numbers", "PATCH", "Train Build"],
"PUT /api/train-builder/:id/locomotives": ["Replace the locomotive set (minimum 1, same yard)", "PUT", "Train Build"],
"POST /api/train-builder/:id/reorder-wagons": ["Persist a drag-reorder of the full consist", "POST", "Train Build"],
"PATCH /api/train-builder/:id/wagons/:wagonId/yard": ["Move one coupled wagon to another yard — refused while any live schedule has the wagon allocated", "PATCH", "Train Build"],
"PATCH /api/train-builder/:id/wagons/yard": ["Move several coupled wagons to another yard in one transaction — refused outright if any is allocated to a live schedule", "PATCH", "Train Build"],
"POST /api/train-builder/:id/wagons": ["Append AVAILABLE wagons from the train's yard to the consist", "POST", "Train Build"],
"DELETE /api/train-builder/:id/wagons/:wagonId": ["Detach one wagon from the consist", "DELETE", "Train Build"],
"POST /api/train-builder/:id/wagons/:wagonId/maintenance": ["Detach one wagon and move it to MAINTENANCE status", "POST", "Train Build"],
@@ -520,16 +539,16 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/train-scheduling/bookings/:bookingId/expire": ["Staff: expire a reservation and free its capacity", "POST", "Train Schedule"],
"POST /api/train-scheduling/bookings/:bookingId/mark-paid": ["Staff: mark a reserved booking paid and allocate it now", "POST", "Train Schedule"],
"POST /api/train-scheduling/bookings/:bookingId/move-schedule": ["Re-point a booking to another OPEN same-route schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/bulk/preview": ["Preview a bulk train schedule", "POST", "Train Schedule"],
// "POST /api/train-scheduling/bulk/preview": ["Preview a bulk train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/bulk/schedules": ["Create a bulk train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/bulk/schedules/:id/assign-bookings": ["Assign bulk bookings to a train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/bulk/schedules/:id/cancel": ["Cancel bulk train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/container/preview": ["Preview a container train schedule", "POST", "Train Schedule"],
// "POST /api/train-scheduling/container/preview": ["Preview a container train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/container/schedules": ["Create a container train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/container/schedules/:id/assign-bookings": ["Assign container bookings to a train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/container/schedules/:id/cancel": ["Cancel container train schedule", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/global-rules": ["Update global train scheduling rules (singleton)", "PATCH", "Train Schedule"],
"POST /api/train-scheduling/preview": ["Preview a mixed-capable train schedule", "POST", "Train Schedule"],
// "POST /api/train-scheduling/preview": ["Preview a mixed-capable train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/adjust-consist": ["Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/arrive": ["Mark a dispatched train arrived (move assets to destination yard, free assets)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/assign-bookings": ["Assign bookings to a train schedule (mixed-capable)", "POST", "Train Schedule"],
@@ -564,6 +583,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"DELETE /api/train-scheduling/schedules/:id/wagons/:trainSetWagonId": ["Remove an empty wagon slot from a train", "DELETE", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/wagons/:wagonId/move-load": ["Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/window-rule": ["Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens", "PATCH", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/wagon-yards": ["Re-plan the yard this departure boards wagons from and/or cuts them at (schedule-only; physical yards untouched, dispatch requires alignment)", "PATCH", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/merge": ["Merge another train into this schedule: its wagons join this consist, a same-day schedule on it is absorbed, and the emptied train is deactivated", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/checkpoints/:sequenceNo": ["Edit a logged leg", "PATCH", "Train Schedule"],
@@ -611,7 +631,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/warehouse-allocation-rules": ["Create a warehouse allocation rule", "POST", "Warehouse"],
"PATCH /api/warehouse-allocation-rules/:id": ["Update a warehouse allocation rule", "PATCH", "Warehouse"],
"DELETE /api/warehouse-allocation-rules/:id": ["Delete a warehouse allocation rule", "DELETE", "Warehouse"],
"POST /api/warehouse-allocation/preview": ["Preview the yard/warehouse/zone a booking would be allocated to", "POST", "Warehouse"],
// "POST /api/warehouse-allocation/preview": ["Preview the yard/warehouse/zone a booking would be allocated to", "POST", "Warehouse"],
"POST /api/warehouse-fee-rules": ["Create a storage / demurrage fee rule", "POST", "Warehouse"],
"PATCH /api/warehouse-fee-rules/:id": ["Update a fee rule", "PATCH", "Warehouse"],
"DELETE /api/warehouse-fee-rules/:id": ["Delete a fee rule", "DELETE", "Warehouse"],

View File

@@ -1,10 +1,11 @@
import { Injectable } from '@nestjs/common';
import { BaseRepository } from '@edr/api-common';
import { InjectRepository } from '@nestjs/typeorm';
import { Between, FindOptionsWhere, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
import { Repository } from 'typeorm';
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { AuditLog } from './entities/audit-log.entity';
import type { AuditReferenceSource } from './audit-reference.registry';
export interface AuditLogQuery {
type?: string;
@@ -12,6 +13,10 @@ export interface AuditLogQuery {
method?: string;
isSuccess?: boolean;
resourceId?: string;
reference?: string;
userName?: string;
title?: string;
q?: string;
from?: Date;
to?: Date;
skip: number;
@@ -40,30 +45,91 @@ export class AuditLogRepository extends BaseRepository<AuditLog> {
);
}
/**
* Resolve the human identifier for one entity row (`WHERE id = $1`).
*
* `source` comes from the static `AUDIT_REFERENCE_SOURCES` registry — never
* from user input — so interpolating its table/column is safe; the id is
* bound as a parameter. Returns null when the row doesn't exist or the
* identifier column is empty.
*/
async lookupReference(
source: AuditReferenceSource,
id: string,
): Promise<string | null> {
const rows = await this.auditLogRepository.manager.query<
{ reference: string | null }[]
>(
`SELECT ${source.column}::varchar AS reference FROM ${source.table} WHERE id = $1::uuid`,
[id],
);
return rows[0]?.reference || null;
}
/**
* Paginated, filtered read. Newest first — every index on this table is
* ordered `created_at DESC` to match.
*
* Query builder rather than `findAndCount`: `q` needs an OR across four
* columns, and `reference` needs the `upper(...) LIKE` shape that matches
* the expression index — neither fits `FindOptionsWhere`.
*/
async search(query: AuditLogQuery): Promise<[AuditLog[], number]> {
const where: FindOptionsWhere<AuditLog> = {};
const qb = this.auditLogRepository.createQueryBuilder('audit_log');
if (query.type) where.type = query.type;
if (query.userId) where.userId = query.userId;
if (query.method) where.method = query.method;
if (query.resourceId) where.resourceId = query.resourceId;
if (query.isSuccess !== undefined) where.isSuccess = query.isSuccess;
if (query.type) qb.andWhere('audit_log.type = :type', { type: query.type });
if (query.userId) qb.andWhere('audit_log.user_id = :userId', { userId: query.userId });
if (query.method) qb.andWhere('audit_log.method = :method', { method: query.method });
if (query.resourceId) {
qb.andWhere('audit_log.resource_id = :resourceId', { resourceId: query.resourceId });
}
if (query.isSuccess !== undefined) {
qb.andWhere('audit_log.is_success = :isSuccess', { isSuccess: query.isSuccess });
}
// Case-insensitive prefix match, shaped to hit idx_audit_logs_reference_upper.
// The explicit <> '' repeats the index's partial predicate — without it the
// planner cannot prove the partial index applies and falls back to a scan.
if (query.reference) {
qb.andWhere("audit_log.reference <> ''").andWhere(
"upper(audit_log.reference) LIKE upper(:reference) || '%'",
{ reference: escapeLike(query.reference) },
);
}
if (query.userName) {
qb.andWhere('audit_log.user_name ILIKE :userName', {
userName: `%${escapeLike(query.userName)}%`,
});
}
if (query.title) {
qb.andWhere('audit_log.title ILIKE :title', {
title: `%${escapeLike(query.title)}%`,
});
}
// One search box across the columns staff actually search by.
// ponytail: ILIKE %…% scans the time-bounded window; add pg_trgm GIN
// indexes if the table grows past a few million rows.
if (query.q) {
const q = `%${escapeLike(query.q)}%`;
qb.andWhere(
`(audit_log.reference ILIKE :q
OR audit_log.resource_id ILIKE :q
OR audit_log.user_name ILIKE :q
OR audit_log.title ILIKE :q)`,
{ q },
);
}
// Date range: either bound may be supplied alone.
if (query.from && query.to) where.createdAt = Between(query.from, query.to);
else if (query.from) where.createdAt = MoreThanOrEqual(query.from);
else if (query.to) where.createdAt = LessThanOrEqual(query.to);
if (query.from) qb.andWhere('audit_log.created_at >= :from', { from: query.from });
if (query.to) qb.andWhere('audit_log.created_at <= :to', { to: query.to });
return this.auditLogRepository.findAndCount({
where,
order: { createdAt: 'DESC' },
skip: query.skip,
take: query.take,
});
return qb
.orderBy('audit_log.created_at', 'DESC')
.skip(query.skip)
.take(query.take)
.getManyAndCount();
}
/** Distinct entity types present, for populating a filter dropdown. */
@@ -76,4 +142,20 @@ export class AuditLogRepository extends BaseRepository<AuditLog> {
return rows.map((row) => row.type);
}
/** Distinct action titles present, for the action filter dropdown. */
async distinctTitles(): Promise<string[]> {
const rows = await this.auditLogRepository
.createQueryBuilder('audit_log')
.select('DISTINCT audit_log.title', 'title')
.orderBy('audit_log.title', 'ASC')
.getRawMany<{ title: string }>();
return rows.map((row) => row.title);
}
}
/** Escape LIKE wildcards so a literal `%`/`_` in the search text stays literal. */
function escapeLike(value: string): string {
return value.replace(/[\\%_]/g, (ch) => `\\${ch}`);
}

View File

@@ -0,0 +1,39 @@
/**
* Where each audited entity type keeps its human identifier — the value staff
* search by (booking reference, train number, invoice number).
*
* Used by `AuditService.record` for a single indexed primary-key lookup at
* write time. Types not listed simply get `reference = ''`; the lookup is
* best-effort and an audit row is never lost over it.
*
* Table and column names are static values from this file — never user input —
* so interpolating them into SQL is safe. Ids are always bound as parameters.
*/
export interface AuditReferenceSource {
/** Schema-qualified table holding the entity. */
readonly table: string;
/** Column with the human identifier. */
readonly column: string;
}
export const AUDIT_REFERENCE_SOURCES: Readonly<Record<string, AuditReferenceSource>> = {
Booking: { table: 'freight.bookings', column: 'reference' },
Contract: { table: 'freight.contracts', column: 'reference' },
// "Schedule" (reschedule module) and "Train Schedule" are the same table.
Schedule: { table: 'freight.train_schedules', column: 'reference' },
'Train Schedule': { table: 'freight.train_schedules', column: 'reference' },
Train: { table: 'freight.trains', column: 'train_number' },
// Train Build routes carry the train id in :id.
'Train Build': { table: 'freight.trains', column: 'train_number' },
Wagon: { table: 'freight.wagons', column: 'wagon_number' },
Locomotive: { table: 'freight.locomotives', column: 'code' },
'EIMS Invoice': { table: 'freight.invoices', column: 'invoice_number' },
// Payment paths mostly carry an invoice id; the ones that don't (e.g.
// redirect-success/:bookingId) miss the lookup and fall back to ''.
Payment: { table: 'freight.invoices', column: 'invoice_number' },
Vehicle: { table: 'freight.vehicles', column: 'plate_number' },
Company: { table: 'freight.companies', column: 'name' },
};
/** Lookups run `WHERE id = $1::uuid` — guard non-uuid ids (template codes…). */
export const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

View File

@@ -43,4 +43,13 @@ export class AuditController {
types(): Promise<string[]> {
return this.auditService.listTypes();
}
@Get('actions')
@BookingStaff(FREIGHT_PERMS.auditLog.view)
@ApiOperation({
summary: 'Distinct action titles present in the audit log (filter dropdown)',
})
actions(): Promise<string[]> {
return this.auditService.listActions();
}
}

View File

@@ -4,6 +4,10 @@ import { PaginatedResponse } from '@edr/types';
import { AuditLog } from './entities/audit-log.entity';
import { AuditLogRepository } from './audit-log.repository';
import { AuditLogQueryDto } from './dto/audit-log-query.dto';
import {
AUDIT_REFERENCE_SOURCES,
UUID_PATTERN,
} from './audit-reference.registry';
import {
buildPaginationMeta,
normalizePagination,
@@ -25,6 +29,7 @@ export class AuditService {
*/
async record(entry: Partial<AuditLog>): Promise<void> {
try {
entry.reference = await this.resolveReference(entry.type, entry.resourceId);
await this.auditLogRepository.record(entry);
} catch (error) {
this.logger.error(
@@ -35,6 +40,34 @@ export class AuditService {
}
}
/**
* Best-effort human identifier (booking reference, train number, …) for the
* entity the action touched — one primary-key lookup against the table
* registered for the type. Always returns a string: '' when the type has no
* registered source, the id isn't a uuid (template codes), the row is gone,
* or the lookup itself fails. A missing reference must never cost the audit
* row, so failures degrade to '' rather than throwing.
*/
private async resolveReference(
type: string | undefined,
resourceId: string | null | undefined,
): Promise<string> {
const source = type ? AUDIT_REFERENCE_SOURCES[type] : undefined;
if (!source || !resourceId || !UUID_PATTERN.test(resourceId)) return '';
try {
const reference = await this.auditLogRepository.lookupReference(source, resourceId);
return reference?.slice(0, 64) ?? '';
} catch (error) {
this.logger.warn(
`Reference lookup failed for ${type} ${resourceId}: ${
error instanceof Error ? error.message : String(error)
}`,
);
return '';
}
}
/** Paginated, filtered audit history, newest first. */
async search(query: AuditLogQueryDto): Promise<PaginatedResponse<AuditLog>> {
const { page, pageSize, skip, take } = normalizePagination(query);
@@ -53,6 +86,10 @@ export class AuditService {
userId: query.userId,
method: query.method,
resourceId: query.resourceId,
reference: query.reference,
userName: query.userName,
title: query.title,
q: query.q,
isSuccess:
query.isSuccess === undefined ? undefined : query.isSuccess === 'true',
from,
@@ -68,4 +105,9 @@ export class AuditService {
async listTypes(): Promise<string[]> {
return this.auditLogRepository.distinctTypes();
}
/** Distinct action titles, for the action filter dropdown. */
async listActions(): Promise<string[]> {
return this.auditLogRepository.distinctTitles();
}
}

View File

@@ -39,6 +39,44 @@ export class AuditLogQueryDto extends PaginationQueryDto {
@MaxLength(64)
resourceId?: string;
@ApiPropertyOptional({
description:
'Human identifier of the affected record — booking reference, schedule number, train number. Case-insensitive prefix match.',
example: 'S-2026-00045',
})
@IsOptional()
@IsString()
@MaxLength(64)
reference?: string;
@ApiPropertyOptional({
description: 'Staff name, case-insensitive substring match.',
example: 'Mulu',
})
@IsOptional()
@IsString()
@MaxLength(150)
userName?: string;
@ApiPropertyOptional({
description: 'Action title, case-insensitive substring match.',
example: 'Cancel booking',
})
@IsOptional()
@IsString()
@MaxLength(255)
title?: string;
@ApiPropertyOptional({
description:
'Free-text search across reference, resource id, staff name and action title.',
example: 'B-2026-00120',
})
@IsOptional()
@IsString()
@MaxLength(100)
q?: string;
@ApiPropertyOptional({
description: 'Filter by outcome: true = succeeded, false = failed.',
})

View File

@@ -86,6 +86,20 @@ export class AuditLog {
@Column({ name: 'resource_id', type: 'varchar', length: 64, nullable: true })
resourceId?: string | null;
/**
* Human identifier of the affected record — booking reference, schedule
* number, train number — resolved at write time from
* `AUDIT_REFERENCE_SOURCES`. This is what staff type into the search box;
* `resourceId` stays the machine id.
*
* `''` (never NULL) when the entity type has no registered source, the
* lookup found nothing, or the row predates the column. Empty string keeps
* search SQL to one shape and matches how pre-existing rows read after the
* metadata-only migration.
*/
@Column({ name: 'reference', type: 'varchar', length: 64, default: '' })
reference!: string;
/**
* Sanitized request body. Secrets are replaced with `[REDACTED]` and uploads
* are reduced to `{ __file, originalName, mimeType, size }` descriptors —

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Patch, Post, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator";
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
import { FreightJwtGuard } from "../../common/freight-jwt.guard";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { AccountService } from "./account.service";
@@ -19,7 +19,7 @@ import {
@ApiTags("auth")
@Controller("me")
@ApiBearerAuth()
@UseGuards(JwtGuard)
@UseGuards(FreightJwtGuard)
export class AccountController {
constructor(private readonly accountService: AccountService) {}

View File

@@ -1,7 +1,7 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@tria-plc/api-common/modules/auth/decorators/current-user.decorator';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { FreightJwtGuard } from '../../common/freight-jwt.guard';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { FreightMeService } from './freight-me.service';
@@ -13,7 +13,7 @@ export class FreightMeController {
constructor(private readonly freightMeService: FreightMeService) {}
@Get()
@UseGuards(JwtGuard)
@UseGuards(FreightJwtGuard)
@ApiOperation({
summary: 'Current user with flat permissionKeys for backoffice gating',
})

View File

@@ -9,6 +9,9 @@ import {
} from '../../common/freight-permission.util';
import { PERMISSIONS_CATALOG } from '../../seed/freight-permissions.registry';
/** One position as the session snapshot carries it. */
type TokenPosition = NonNullable<TCurrentUser['employee']>['position'];
@Injectable()
export class FreightMeService {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
@@ -65,49 +68,63 @@ export class FreightMeService {
}
async getEnrichedProfile(user: TCurrentUser) {
const positionId = user.employee?.position?.id;
const [positionType, positionTypePermissionKeys] = await Promise.all([
this.lookupPositionType(positionId),
this.lookupPositionTypePermissions(positionId),
]);
const employeeRecord = user.employee as
| (typeof user.employee & { positions?: TokenPosition[] })
| undefined;
// Merge the type-level grants into the position's own permission list so
// BOTH consumers see them: `collectPermissionKeys` below, and the
// backoffice's `getPermissionKeys`, which walks this same nested array.
const positionPermissions = [
...(user.employee?.position?.permissions ?? []),
];
const seenPermissionKeys = new Set(
positionPermissions.map((p) => p?.key).filter(Boolean),
// `FreightJwtGuard` restores every position the login snapshot holds; the
// stock IAM guard only ever leaves the single `position`. Fall back to it
// so a request that somehow skipped our guard still resolves one post
// rather than none.
const rawPositions: TokenPosition[] = employeeRecord?.positions?.length
? employeeRecord.positions
: employeeRecord?.position
? [employeeRecord.position]
: [];
const enrichedPositions = await Promise.all(
rawPositions.map(async (position) => {
const [positionType, positionTypePermissionKeys] = await Promise.all([
this.lookupPositionType(position.id),
this.lookupPositionTypePermissions(position.id),
]);
// Merge the type-level grants into this position's own permission list
// so BOTH consumers see them: `collectPermissionKeys` below, and the
// backoffice's `getPermissionKeys`, which walks this nested array.
const permissions = [...(position.permissions ?? [])];
const seen = new Set(permissions.map((p) => p?.key).filter(Boolean));
for (const key of positionTypePermissionKeys) {
if (!seen.has(key)) {
seen.add(key);
permissions.push({ key } as (typeof permissions)[number]);
}
}
return {
positionTypePermissionKeys,
position: {
id: position.id,
key: position.key,
employeePositionId: position.employeePositionId,
name: position.name,
isDelegate: position.isDelegate,
parentPositionId: position.parentPositionId,
permissions,
positionType,
},
};
}),
);
for (const key of positionTypePermissionKeys) {
if (!seenPermissionKeys.has(key)) {
seenPermissionKeys.add(key);
positionPermissions.push({ key } as (typeof positionPermissions)[number]);
}
}
const employee = user.employee
const employee = employeeRecord
? [
{
id: user.employee.id,
organizationId: user.employee.organizationId,
unitId: user.employee.unitId,
name: user.employee.name,
positions: user.employee.position
? [
{
id: user.employee.position.id,
key: user.employee.position.key,
employeePositionId: user.employee.position.employeePositionId,
name: user.employee.position.name,
isDelegate: user.employee.position.isDelegate,
parentPositionId: user.employee.position.parentPositionId,
permissions: positionPermissions,
positionType,
},
]
: [],
id: employeeRecord.id,
organizationId: employeeRecord.organizationId,
unitId: employeeRecord.unitId,
name: employeeRecord.name,
positions: enrichedPositions.map((p) => p.position),
},
]
: [];
@@ -118,7 +135,7 @@ export class FreightMeService {
const permissionKeys = [
...new Set([
...collectPermissionKeys(user),
...positionTypePermissionKeys,
...enrichedPositions.flatMap((p) => p.positionTypePermissionKeys),
]),
];

View File

@@ -37,7 +37,11 @@ import { InvoiceLine } from "./entities/invoice-line.entity";
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
import { InvoiceLineRepository } from "./invoice-line.repository";
import { nextDailyInvoiceNumber } from "./invoice-numbering.util";
import { applySettlement, round2 } from "./invoice-settlement.util";
import {
applySettlement,
invoicePaymentMethodExpr,
round2,
} from "./invoice-settlement.util";
import { InvoiceRepository } from "./invoice.repository";
/** Options forwarded to the payment gateway when settling an invoice. */
@@ -111,6 +115,8 @@ export interface InvoiceListFilters {
statuses?: Freight.InvoiceStatus[];
sources?: string[];
eimsStatuses?: string[];
/** Settled payment method, normalised UPPER_SNAKE — see `invoicePaymentMethodExpr`. */
paymentMethods?: string[];
currency?: string;
search?: string;
issuedFrom?: string;
@@ -125,6 +131,13 @@ export interface InvoiceListFilters {
tradeDirections?: string[];
}
/**
* The list/summary query builders both alias the invoice as `invoice` and the
* joined gateway payment as `payment`; TypeORM rewrites those alias.property
* references into real quoted columns.
*/
const PAYMENT_METHOD_EXPR = invoicePaymentMethodExpr("invoice", "payment");
const DEFAULT_DUE_DAYS = 14;
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
@@ -294,6 +307,13 @@ export class BillingService {
eimsStatuses: filter.eimsStatuses,
});
}
if (filter.paymentMethods?.length) {
// Requires the `payment` alias to be joined by the caller — both call
// sites do, unconditionally, so this can never reference a missing alias.
qb.andWhere(`${PAYMENT_METHOD_EXPR} IN (:...paymentMethods)`, {
paymentMethods: filter.paymentMethods,
});
}
if (filter.currency) {
// Stored casing has drifted ("usd" rows exist) — compare normalised.
qb.andWhere("UPPER(invoice.currency) = :currency", {
@@ -333,20 +353,24 @@ export class BillingService {
qb.andWhere("invoice.balanceAmount > 0 AND invoice.dueAt < now()");
}
if (filter.search) {
// Searches what the row actually shows: its number, who it bills, and
// the source record behind it (booking reference, GRN, shipping line).
// Searches what the row actually shows: its number, who it bills, the
// source record behind it (booking reference, PNR, GRN, shipping line)
// and the payment references a customer or a provider support desk would
// quote back — the gateway transaction id and our merchant order id.
// The raw `sourceId` stays matchable so a pasted UUID still resolves.
// Requires the `company` alias — every caller of this joins it.
// Requires the `company` and `payment` aliases — every caller joins both.
qb.andWhere(
`(invoice.invoiceNumber ILIKE :search
OR invoice.sourceId ILIKE :search
OR company.name ILIKE :search
OR payment.transactionId ILIKE :search
OR payment.merchantOrderId ILIKE :search
OR EXISTS (
SELECT 1 FROM freight.bookings b
LEFT JOIN freight.warehouse_inventory wi ON wi.booking_id = b.id
LEFT JOIN freight.first_mile fm ON fm.booking_id = b.id
LEFT JOIN freight.last_mile lm ON lm.booking_id = b.id
WHERE b.reference ILIKE :search
WHERE (b.reference ILIKE :search OR b.pnr_code ILIKE :search)
AND (b.id::text = invoice.source_id
OR wi.id::text = invoice.source_id
OR fm.id::text = invoice.source_id
@@ -388,6 +412,9 @@ export class BillingService {
.getRepository(Invoice)
.createQueryBuilder("invoice")
.leftJoinAndSelect("invoice.company", "company")
// The gateway payment behind the invoice: the settled method and the
// provider's transaction reference both live on it, and nowhere else.
.leftJoinAndSelect("invoice.payment", "payment")
// sortBy is whitelisted through INVOICE_SORT_COLUMNS, never interpolated
// raw. The id tiebreaker keeps paging stable when the sort column ties
// (issuedAt is null on every DRAFT row).
@@ -544,6 +571,7 @@ export class BillingService {
// Joined, not selected: `applyInvoiceFilters` searches the customer name,
// so the alias has to exist even though the summary only sums money.
.leftJoin("invoice.company", "company")
.leftJoin("invoice.payment", "payment")
.select("invoice.currency", "currency")
.addSelect("SUM(invoice.paidAmount)", "collected")
.groupBy("invoice.currency");
@@ -757,7 +785,7 @@ export class BillingService {
/** Invoice header plus its line items. */
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
const invoice = await this.invoices.findById(id, {
relations: { company: true, companyProfile: true },
relations: { company: true, companyProfile: true, payment: true },
});
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
const [hydrated] = await this.attachShippingLineCompanies([invoice]);

View File

@@ -15,6 +15,7 @@ import {
} from "class-validator";
import { EimsInvoiceStatus } from "../../eims/eims-registration.types";
import { INVOICE_PAYMENT_METHODS } from "../invoice-settlement.util";
/** Columns the invoice list may be ordered by -> their query-builder expression. */
export const INVOICE_SORT_COLUMNS: Record<string, string> = {
@@ -96,6 +97,19 @@ export class FilterInvoiceDto {
@IsIn(Object.values(EimsInvoiceStatus), { each: true })
eimsStatuses?: EimsInvoiceStatus[];
/**
* Settled payment method (`?paymentMethods=CBE_BILL,BANK_TRANSFER`). Values are
* the normalised UPPER_SNAKE vocabulary of `invoicePaymentMethodExpr`. Not
* validated against a fixed list — the manual pay endpoint takes a free-form
* method, so an `IsIn` here would silently drop a real value.
*/
@ApiPropertyOptional({ isArray: true, enum: INVOICE_PAYMENT_METHODS })
@IsOptional()
@Transform(csv)
@IsArray()
@IsString({ each: true })
paymentMethods?: string[];
/** Manual-payments worklist and the invoice list: restrict to one currency. */
@ApiPropertyOptional({ enum: ["USD", "ETB"] })
@IsOptional()

View File

@@ -60,11 +60,7 @@ const context = (over: Partial<EimsMapperContext> = {}): EimsMapperContext => ({
unitDefault: "PCS",
incomeWithholdValue: 0,
transactionWithholdValue: 0,
buyerCountryCode: "231", // test-only, not a confirmed real MoR code
buyerCountryCodes: {},
buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: {},
buyerCityCodes: {},
buyerGeo: { Country: "70", Region: "6", City: "31", Wereda: "190" },
...over,
});
@@ -90,26 +86,24 @@ describe("toEimsInvoice", () => {
expect(doc.SellerDetails).toBe(seller);
});
it("maps the buyer from the company row and leaves unmodelled fields null", () => {
it("maps the buyer from the company row and omits Id fields for a TIN-identified buyer", () => {
const doc = toEimsInvoice(invoice(), seller, context());
// MoR rule 7004 rejects an explicit IdType/IdNumber null — the keys must be absent.
expect(doc.BuyerDetails).toEqual({
City: null,
// company.country is "Ethiopia" (the domestic default) — resolves to context's flat
// buyerCountryCode fallback, not null, per resolveCountryCode.
Country: "231",
// Resolved by the registration service before the counter was reserved; the mapper copies.
City: "31",
Country: "70",
Email: "buyer@abc.et",
HouseNumber: "NEW",
IdNumber: null,
IdType: null,
Tin: "0999930000",
LegalName: "ABC Trading PLC",
Phone: "0912345678",
Region: "13",
Region: "6",
Zone: "SHA",
Kebele: "03",
VatNumber: "123475885858",
Wereda: "574",
Wereda: "190",
});
});
@@ -284,108 +278,39 @@ describe("toEimsInvoice", () => {
});
describe("toEimsInvoice — MoR field constraints", () => {
it("passes a buyer region through when it is already a MoR code", () => {
/**
* Geography is no longer resolved here. `resolveMorGeo` runs in the registration service, ahead
* of the counter reservation, and hands the mapper finished MoR codes — so what these cover is
* that the resolved values reach the right `BuyerDetails` fields untouched. The lookup rules
* themselves (hierarchy, aliases, ambiguity) are covered in `mor-location.resolver.spec.ts`.
*/
it("puts the resolved MoR codes on BuyerDetails, unmodified and as strings", () => {
const doc = toEimsInvoice(invoice(), seller, context());
expect(doc.BuyerDetails.Region).toBe("13");
expect(doc.BuyerDetails.Country).toBe("70");
expect(doc.BuyerDetails.Region).toBe("6");
expect(doc.BuyerDetails.City).toBe("31");
expect(doc.BuyerDetails.Wereda).toBe("190");
for (const field of ["Country", "Region", "City", "Wereda"] as const) {
expect(typeof doc.BuyerDetails[field]).toBe("string");
}
});
it("maps a region name to its code, ignoring case and spacing", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, region: " addis ababa " } }),
seller,
context({ buyerRegionCodes: { "Addis Ababa": "13" } }),
);
expect(doc.BuyerDetails.Region).toBe("13");
});
it("refuses to file a buyer whose region has no mapping", () => {
expect(() =>
toEimsInvoice(
invoice({ company: { ...invoice().company!, region: "Somewhere Else" } }),
seller,
context(),
),
).toThrow(/not a MoR Region code and has no mapping/);
});
it("refuses a buyer with no region at all rather than guessing one", () => {
expect(() =>
toEimsInvoice(
invoice({ company: { ...invoice().company!, region: null } }),
seller,
context(),
),
).toThrow(/buyer Region \(unset\)/);
});
it("passes a buyer wereda through when it is already a MoR code", () => {
it("never emits an Open Admin Data ETxx identifier as a location", () => {
const doc = toEimsInvoice(invoice(), seller, context());
expect(doc.BuyerDetails.Wereda).toBe("574");
for (const field of ["Country", "Region", "City", "Wereda"] as const) {
expect(doc.BuyerDetails[field]).toMatch(/^[0-9]+$/);
}
});
it("maps a wereda name to its code", () => {
it("keeps BuyerDetails.Zone as the buyer's own zone name — MoR takes that one as prose", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, woreda: "Yeka" } }),
invoice({ company: { ...invoice().company!, zone: "Fafen" } }),
seller,
context({ buyerWeredaCodes: { Yeka: "99" } }),
context(),
);
expect(doc.BuyerDetails.Wereda).toBe("99");
});
it("refuses to file a buyer whose wereda has no mapping", () => {
expect(() =>
toEimsInvoice(
invoice({ company: { ...invoice().company!, woreda: "Yeka" } }),
seller,
context({ buyerWeredaCodes: {} }),
),
).toThrow(/buyer Wereda "Yeka".*EIMS_BUYER_WEREDA_CODES/);
});
it("derives City from the buyer's zone via the city code map", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, zone: "Kirkos" } }),
seller,
context({ buyerCityCodes: { Kirkos: "101" } }),
);
expect(doc.BuyerDetails.City).toBe("101");
});
it("leaves City null (not a throw) when the buyer's zone has no city mapping — City is optional", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, zone: "Somewhere Else" } }),
seller,
context({ buyerCityCodes: {} }),
);
expect(doc.BuyerDetails.City).toBeNull();
});
it("maps a buyer country name to its code via the country code map", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, country: "Djibouti" } }),
seller,
context({ buyerCountryCodes: { Djibouti: "071" } }),
);
expect(doc.BuyerDetails.Country).toBe("071");
});
it("falls back to the flat domestic country code only for Ethiopia, not any unmapped country", () => {
const doc = toEimsInvoice(
invoice({ company: { ...invoice().company!, country: "Ethiopia" } }),
seller,
context({ buyerCountryCode: "231", buyerCountryCodes: {} }),
);
expect(doc.BuyerDetails.Country).toBe("231");
});
it("refuses a genuinely foreign buyer country with no mapping — never silently files it as Ethiopia", () => {
expect(() =>
toEimsInvoice(
invoice({ company: { ...invoice().company!, country: "Kenya" } }),
seller,
context({ buyerCountryCode: "231", buyerCountryCodes: {} }),
),
).toThrow(/buyer Country "Kenya".*EIMS_BUYER_COUNTRY_CODES/);
expect(doc.BuyerDetails.Zone).toBe("Fafen");
expect(doc.BuyerDetails.City).toBe("31");
});
it("emits NatureOfSupplies lowercase, whatever case it was configured in", () => {

View File

@@ -15,6 +15,7 @@
* authoritative: they are passed through or overridable rather than validated against a fixed set.
*/
import { MorGeoCodes } from "../../config/mor-location.resolver";
import { round2 } from "./invoice-settlement.util";
/** Only proven-required constant: the 400 SCHEMA ERROR sample rejects a payload without it. */
@@ -34,8 +35,9 @@ export interface EimsBuyerDetails {
City: string | null;
Email: string | null;
HouseNumber: string | null;
IdNumber: string | null;
IdType: string | null;
/** Omitted entirely for a TIN-identified buyer — MoR rule 7004 rejects an explicit null. */
IdNumber?: string;
IdType?: string;
Tin: string;
LegalName: string;
Phone: string | null;
@@ -235,35 +237,15 @@ export interface EimsMapperContext {
*/
relatedDocument?: string | null;
/**
* Domestic fallback only, applied when `company.country` is empty or "Ethiopia" and not already
* in `buyerCountryCodes` — see that field. Never applied to a genuinely foreign buyer.
*/
buyerCountryCode?: string | null;
/** Country name → MoR code. Format unconfirmed, so looked up by name only, not digit-validated. */
buyerCountryCodes: Record<string, string>;
/**
* Region name → MoR numeric code, for buyers whose stored region is free text.
* The buyer's MoR location codes — `Country`/`Region`/`City`/`Wereda`, already resolved from the
* Ministry's location master by `resolveMorGeo`.
*
* `companies.region` holds names ("Addis Ababa") while MoR validates `BuyerDetails.Region`
* against `^[0-9]{1,3}$`. A stored value that is already a code passes through; anything else
* must be in this map or the mapping **fails locally** — sending a guessed region code onto a
* tax document is worse than refusing to file.
* Resolved by the caller, not here, and deliberately so: geographic resolution can fail (unknown
* or ambiguous address) and that failure must happen **before** an EIMS counter is reserved, so a
* bad company address never burns a sequence number. See `mor-location.resolver.ts` for why the
* lookup has to be hierarchical, and `EimsInvoiceRegistrationService` for where it runs.
*/
buyerRegionCodes: Record<string, string>;
/**
* Wereda name → MoR code, same shape as `buyerRegionCodes`. `companies.woreda` holds names
* ("Yeka") or codes inconsistently; unlike Region, MoR has never named a Wereda regex in an
* error, so this is precautionary rather than confirmed — but the fix is identical either way:
* fail locally on an unmapped name rather than file a guess.
*/
buyerWeredaCodes: Record<string, string>;
/**
* Buyer *zone* name → MoR City code. `Company` has no dedicated city column; Zone is the
* closest match in EDR's own data. Unlike Region/Wereda, City is optional — MoR has already
* accepted a live filing with it null — so an unmapped zone resolves to null, it does not fail
* the mapping.
*/
buyerCityCodes: Record<string, string>;
buyerGeo: MorGeoCodes;
buyerIdType?: string | null;
buyerIdNumber?: string | null;
/** Required when the invoice currency is not ETB. */
@@ -273,14 +255,6 @@ export interface EimsMapperContext {
formatDate?: (issuedAt: Date) => string;
}
/**
* MoR's own constraint on `Region`: one to three digits, confirmed by its 400 SCHEMA ERROR. Reused
* as the pass-through test for `Wereda` too — every Wereda value MoR has actually shown us (seller
* "12"/"13", the collection's "574") fits the same shape, though MoR has not named a Wereda regex
* the way it named Region's.
*/
const LOCATION_CODE = /^[0-9]{1,3}$/;
/**
* The only two values MoR accepts for `NatureOfSupplies`, lowercase.
*
@@ -309,87 +283,6 @@ export const formatEimsDate = (issuedAt: Date): string =>
* an unissued invoice, unresolved line tax, a line/total mismatch, or a non-ETB invoice with no
* exchange rate.
*/
/**
* A buyer's location value (Region, Wereda or City) as a MoR code: passed through when already
* numeric, otherwise looked up by name (case- and space-insensitive).
*
* Region/Wereda are required: an unmapped value throws — sending a guessed code onto a tax
* document is worse than refusing to file. City is optional (`required: false`, City's own
* caller) — MoR has already accepted a live filing with it null, so an unmapped zone resolves to
* null instead of blocking the invoice.
*/
function resolveLocationCode(
field: "Region" | "Wereda" | "City",
value: string | null | undefined,
codes: Record<string, string>,
envVar: string,
invoiceNumber: string,
opts: { required?: boolean } = {},
): string | null {
const raw = (value ?? "").trim();
if (LOCATION_CODE.test(raw)) return raw;
const key = raw.toLowerCase().replace(/\s+/g, " ");
const mapped = Object.entries(codes).find(
([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key,
)?.[1];
if (mapped && LOCATION_CODE.test(mapped)) return mapped;
if (opts.required === false) return null;
throw new Error(
`EIMS mapping: invoice ${invoiceNumber} has buyer ${field} ${raw ? `"${raw}"` : "(unset)"}, ` +
`which is not a MoR ${field} code and has no mapping. Add it to ${envVar}.`,
);
}
/**
* A buyer's `Country` as a MoR code: looked up by name in `codes` first; when unmapped, applies
* `domesticFallback` only if the stored country is empty or "Ethiopia" (the DB column's default).
* A genuinely foreign, unmapped country throws rather than silently filing as Ethiopia — same
* "fail locally, don't guess" rule as `resolveLocationCode`, but never digit-validated: MoR's
* Country code format is unconfirmed, unlike Region/Wereda's proven `^[0-9]{1,3}$`.
*/
function resolveCountryCode(
country: string | null | undefined,
codes: Record<string, string>,
domesticFallback: string | null,
invoiceNumber: string,
): string | null {
const raw = (country ?? "").trim();
const key = raw.toLowerCase().replace(/\s+/g, " ");
const mapped = Object.entries(codes).find(
([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key,
)?.[1];
if (mapped) return mapped;
if ((!raw || key === "ethiopia") && domesticFallback) return domesticFallback;
throw new Error(
`EIMS mapping: invoice ${invoiceNumber} has buyer Country "${raw || "(unset)"}", which has no ` +
"MoR country code mapping. Add it to EIMS_BUYER_COUNTRY_CODES.",
);
}
/**
* Same name-or-code resolution as `resolveLocationCode`, for a caller with no invoice to attach an
* error to and that must never throw — currently only `EimsSellerCacheService`, resolving
* e-Trade's region/zone/woreda *names* for EDR's own seller identity. Pass-through numeric code,
* name lookup, `undefined` on no match — the caller falls back to static config either way.
*/
export function resolveOptionalCode(
value: string | null | undefined,
codes: Record<string, string>,
): string | undefined {
const raw = (value ?? "").trim();
if (LOCATION_CODE.test(raw)) return raw;
const key = raw.toLowerCase().replace(/\s+/g, " ");
const mapped = Object.entries(codes).find(
([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key,
)?.[1];
return mapped && LOCATION_CODE.test(mapped) ? mapped : undefined;
}
export function toEimsInvoice(
invoice: EimsMapperInvoice,
seller: EimsSellerDetails,
@@ -511,46 +404,24 @@ export function toEimsInvoice(
return {
BuyerDetails: {
// No dedicated city column on Company — Zone is the closest match; optional (see
// resolveLocationCode's City comment).
City: resolveLocationCode(
"City",
company.zone,
context.buyerCityCodes,
"EIMS_BUYER_CITY_CODES",
invoice.invoiceNumber,
{ required: false },
),
// Country/Region/City/Wereda are MoR location codes resolved from the Ministry's own
// location master *before* this mapper ran, and before an EIMS counter was reserved — see
// EimsMapperContext.buyerGeo. `Zone` alongside them is the buyer's free-text zone name,
// which MoR takes as prose, not a code.
City: context.buyerGeo.City,
Email: company.email ?? null,
HouseNumber: company.houseNo ?? null,
IdNumber: context.buyerIdNumber ?? null,
IdType: context.buyerIdType ?? null,
...(context.buyerIdNumber != null ? { IdNumber: context.buyerIdNumber } : {}),
...(context.buyerIdType != null ? { IdType: context.buyerIdType } : {}),
Tin: company.tin,
LegalName: company.name,
Phone: company.phone ?? null,
Region: resolveLocationCode(
"Region",
company.region,
context.buyerRegionCodes,
"EIMS_BUYER_REGION_CODES",
invoice.invoiceNumber,
),
Country: resolveCountryCode(
company.country,
context.buyerCountryCodes,
context.buyerCountryCode ?? null,
invoice.invoiceNumber,
),
Region: context.buyerGeo.Region,
Country: context.buyerGeo.Country,
Zone: company.zone ?? null,
Kebele: company.kebele ?? null,
VatNumber: company.vatNumber ?? null,
Wereda: resolveLocationCode(
"Wereda",
company.woreda,
context.buyerWeredaCodes,
"EIMS_BUYER_WEREDA_CODES",
invoice.invoiceNumber,
),
Wereda: context.buyerGeo.Wereda,
},
DocumentDetails: {
DocumentNumber: context.documentNumber,

View File

@@ -34,3 +34,43 @@ export function applySettlement(
const balanceAmount = Math.max(0, round2(total - paidAmount));
return { paidAmount, balanceAmount, fullyPaid: paidAmount >= total };
}
/**
* SQL for an invoice's settled payment method, normalised to one vocabulary.
*
* Two sources have to be merged: gateway settlements carry the real provider on
* the linked `freight.payments` row (`cbe-bill`, `telebirr`, …) while the
* invoice's own `payments` ledger only records a flat `"GATEWAY"`; manual
* settlements have no payments row at all and the ledger is the ONLY source
* (`BANK_TRANSFER`, `OFFLINE`, or whatever `PayInvoiceDto.method` carried).
* So: provider first, newest ledger entry as the fallback.
*
* `-> -1` is the last ledger element — the ledger is appended newest-last.
* `::text` is not cosmetic: `payments.method` is a real Postgres enum, and
* COALESCE against a text fallback fails without the cast.
*
* Normalised UPPER_SNAKE so `cbe-bill` and a hand-typed `CBE_BILL` are one
* value on screen, in the filter and in the export.
*/
export const invoicePaymentMethodExpr = (invoice: string, payment: string): string =>
`UPPER(REPLACE(COALESCE(${payment}.method::text, ${invoice}.payments -> -1 ->> 'method'), '-', '_'))`;
/**
* The methods the filter offers. Not exhaustive by construction — the manual
* pay endpoint takes a free-form `method` string — so nothing validates against
* this list; it is the pick-list, not a constraint.
*/
export const INVOICE_PAYMENT_METHODS = [
"TELEBIRR",
"CBE_BIRR",
"CBE_BILL",
"EBIRR",
"WAAFI",
"CARD",
"DMONEY",
"CAC_BANK",
"BANK_TRANSFER",
"OFFLINE",
/** Settled at a gateway whose provider row is no longer linked. */
"GATEWAY",
] as const;

View File

@@ -26,9 +26,18 @@ import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
import { ContainerValidationService } from './container-validation.service';
/**
* One physical container over its VGM limit. Weight limits are per container,
* so an overloaded box is reported (and billed) on its own tons above the
* limit — a lighter box on the same line never absorbs them.
*/
export interface OverweightLine {
containerTypeCode: string;
/** Container number when known, else "<code> #2" — identifies the box. */
containerLabel: string;
/** This container's VGM, not the line total. */
totalVgmTons: number;
/** The per-container limit. */
maxAllowedTons: number;
excessTons: number;
}
@@ -250,9 +259,10 @@ export class BookingPricingService {
clearanceBlocked.push(...clearance.blocked);
}
// Overweight detail for the customer: map the engine's per-line results back
// to the booking's container lines (same order) for code + weights. maxAllowed
// is derived from the line total minus the excess the engine computed.
// Overweight detail for the customer: one row per over-limit CONTAINER,
// mapped back to the booking's container lines (same order) for the code and
// the physical container numbers. maxAllowed is the per-container limit,
// recovered from that container's weight minus its own excess.
const overweightLines: OverweightLine[] = [];
const containerLines = (booking.bookingContainers ?? []).filter(
(bc) => bc.containerTypeId != null,
@@ -261,8 +271,6 @@ export class BookingPricingService {
const wr = ruleResult.containerWeightResults[i];
if (!wr?.isOverweight) continue;
const line = containerLines[i];
const totalVgmTons = Number(line?.totalVgmTons ?? 0);
const excessTons = Number(wr.overweightExcessTons ?? 0);
let code = line?.containerSize ?? '';
if (line?.containerTypeId) {
try {
@@ -271,12 +279,32 @@ export class BookingPricingService {
// fall back to the container size label
}
}
overweightLines.push({
containerTypeCode: code,
totalVgmTons,
maxAllowedTons: Math.max(0, totalVgmTons - excessTons),
excessTons,
});
const numbers = (line?.units ?? [])
.slice()
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
.map((u) => u.containerNumber);
// Legacy weight results carry no per-unit detail (a line total only) —
// report the line as a single row, as before.
const units = wr.overweightUnits?.length
? wr.overweightUnits
: [
{
unitIndex: 0,
vgmTons: Number(line?.totalVgmTons ?? 0),
excessTons: Number(wr.overweightExcessTons ?? 0),
},
];
for (const u of units) {
overweightLines.push({
containerTypeCode: code,
containerLabel:
(u.unitIndex > 0 ? numbers[u.unitIndex - 1] : null) ||
(u.unitIndex > 0 ? `${code} #${u.unitIndex}` : code),
totalVgmTons: u.vgmTons,
maxAllowedTons: Math.max(0, u.vgmTons - u.excessTons),
excessTons: u.excessTons,
});
}
}
return {
@@ -346,6 +374,13 @@ export class BookingPricingService {
quantity: qty,
vgmPerUnitTons: vgm,
totalVgmTons: qty * vgm,
// Real per-box weights when the booking recorded them: weight
// limits are per container, so 22/18/20t is 2t over on the first
// box even though the line total fits a 3x20t allowance.
unitVgmTons: (bc.units ?? [])
.slice()
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
.map((u) => Number(u.vgmTons ?? 0)),
isReefer: ct.isReefer,
// Per-container opt-ins — PER_CONTAINER surcharges bill these.
hazardousQuantity: Number(bc.hazardousQuantity ?? 0),

View File

@@ -440,7 +440,9 @@ export class BookingTransitionService {
assertBookingStatus(booking, ["SELECTED_FOR_BATCH"]);
// Consolidated pair: the shared wagon dies with this hold. An unpaid
// partner's hold is released with it (both cancel, no fee); a PAID partner
// keeps the whole wagon and this canceller owes the cancellation fee.
// cannot board alone, so the partnerLapsed listener cancels it too, with
// the cancellation fee — this unpaid canceller owes nothing (fees only
// apply to paid bookings).
const partnerId = booking.consolidationPartnerId;
if (partnerId) {
const partner = await this.bookingsService.findById(partnerId);
@@ -452,7 +454,7 @@ export class BookingTransitionService {
);
if (partnerPaid) {
this.events.emit("booking.consolidation.partnerLapsed", {
expiredBookingId: booking.id,
paidBookingId: partnerId,
});
} else if (!["CANCELLED", "EXPIRED"].includes(partner.status)) {
const partnerReason = "Cancelled with its consolidation partner";
@@ -592,10 +594,11 @@ export class BookingTransitionService {
// Consolidated pair: a shared wagon never ships half-full, so cancelling
// one half settles the other too. Neither paid → both cancel, no fee. A
// PAID partner instead keeps the whole wagon and the unpaid canceller
// owes the cancellation fee (opened by the partnerLapsed listener). A
// PAID booking itself never comes through here (status gate above) — it
// cancels via wagon cancellation, where the fee machinery lives.
// PAID partner cannot board alone, so the partnerLapsed listener cancels
// it too, with the cancellation fee — the unpaid canceller owes nothing
// (fees only apply to paid bookings). A PAID booking itself never comes
// through here (status gate above) — it cancels via wagon cancellation,
// where the fee machinery lives.
const partnerId = booking.consolidationPartnerId;
if (partnerId) {
const partner = await this.bookingsService.findById(partnerId);
@@ -607,7 +610,7 @@ export class BookingTransitionService {
);
if (partnerPaid) {
this.events.emit("booking.consolidation.partnerLapsed", {
expiredBookingId: booking.id,
paidBookingId: partnerId,
});
} else if (!["CANCELLED", "EXPIRED"].includes(partner.status)) {
const partnerReason = "Cancelled with its consolidation partner";

View File

@@ -40,3 +40,70 @@ describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => {
expect(cut.weightTons).toBeCloseTo(62.625, 3);
});
});
/**
* Odd-20ft credit rebook: the rebooked booking shares a wagon again, so GL
* must pick the consolidation partner — no partner, no rebook; a partner
* already paired elsewhere is refused.
*/
describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () => {
const units = Array.from({ length: 3 }, (_, i) => ({
containerSize: '20ft',
containerNumber: `CONT${i}`,
sealNumber: null,
vgmTons: 10,
isHazardous: false,
isReefer: false,
}));
const row = {
id: 'wc1',
bookingId: 'b1',
status: 'CREDIT_AVAILABLE',
creditAmount: 100,
cancelledQuantities: { bySize: { '20ft': 3 }, units },
};
const source = {
id: 'b1',
contractId: 'c1',
paymentCurrency: 'USD',
originYardId: 'y1',
destinationYardId: 'y2',
tradeDirection: 'IMPORT',
};
const makeSvc = (partner?: unknown) => {
const svc = Object.create(BookingWagonCancellationService.prototype) as Record<
string,
unknown
> & {
rebook(id: string, dto: unknown): Promise<unknown>;
};
svc.repo = { findById: async () => row };
svc.bookingsRepository = {
findById: async () => source,
findByIdWithFiles: async () => partner ?? null,
};
return svc;
};
it('refuses an odd-20ft rebook without a GL-picked partner', async () => {
await expect(
makeSvc().rebook('wc1', { scheduledDate: '2026-09-01' }),
).rejects.toThrow(/pick a consolidation partner/i);
});
it('refuses a partner that already shares a wagon', async () => {
const paired = {
id: 'p1',
reference: 'BK-1',
status: 'SUBMITTED',
consolidationPartnerId: 'someone-else',
};
await expect(
makeSvc(paired).rebook('wc1', {
scheduledDate: '2026-09-01',
partnerBookingId: 'p1',
}),
).rejects.toThrow(/already shares a wagon/i);
});
});

View File

@@ -7,7 +7,7 @@ import {
Logger,
NotFoundException,
} from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
import { ExchangeService } from '@edr/api-common';
import { Freight, NotificationAudience, NotificationType } from '@edr/types';
import { DataSource, EntityManager, In, IsNull } from 'typeorm';
@@ -126,6 +126,7 @@ export class BookingWagonCancellationService {
@Inject(forwardRef(() => FirstMileService))
private readonly firstMile: FirstMileService,
private readonly inbox: NotificationInboxService,
private readonly events: EventEmitter2,
) {}
// ── T1: request ────────────────────────────────────────────────────────────
@@ -383,6 +384,9 @@ export class BookingWagonCancellationService {
status: 'CANCELLED',
trainScheduleId: null,
requestedTrainScheduleId: null,
// A dead booking holds no shipment day — leaving it set lets the
// stranded-PAID day sweep pick the booking up and resurrect it.
scheduledDate: null,
});
await this.detachFromSchedule(b);
}
@@ -538,38 +542,73 @@ export class BookingWagonCancellationService {
}
/**
* The batch engine expired an UNPAID booking whose consolidation partner had
* already PAID: the paid partner keeps the whole wagon at no extra cost; the
* lapsed side owes the cancellation fee on its own wagons — shared wagon
* included (ceil). Credit is 0 (nothing was paid); once the fee settles GL
* rebooks the customer through a normal new booking.
* A consolidation pair broke with only one side PAID: the unpaid half
* expired/cancelled fee-free (cancellation fees only ever apply to a paid
* booking), and the PAID half cannot board either — its odd 20ft has no
* partner for the shared wagon. So the PAID booking is cancelled too, owing
* the cancellation fee on ceil of its own fractional wagons (shared wagon
* included); its paid freight is kept as rebooking credit. Once the fee
* settles, GL staff rebook it through a normal new booking, where its odd
* 20ft goes through consolidation pairing again.
*/
@OnEvent('booking.consolidation.partnerLapsed')
async onConsolidationPartnerLapsed(payload: {
expiredBookingId: string;
paidBookingId: string;
}): Promise<void> {
try {
const booking = await this.bookingsRepository.findById(
payload.expiredBookingId,
payload.paidBookingId,
);
if (!booking) return;
if (['CANCELLED', 'EXPIRED', 'COMPLETED'].includes(booking.status)) return;
if (await this.repo.findOpenForBooking(booking.id)) return; // already charged
const row = await this.openConsolidationBreak(
booking,
'ceil',
0,
'Expired while its consolidation partner had paid — cancellation fee applies',
this.creditFor(booking, Number(booking.wagonsRequired ?? 0)),
'Consolidation partner lapsed unpaid — paired booking cancelled, cancellation fee applies',
);
if (row.status !== 'FEE_PENDING') return; // nothing owed
await this.dataSource.getRepository(Booking).update(booking.id, {
status: 'CANCELLED',
trainScheduleId: null,
requestedTrainScheduleId: null,
// A dead booking holds no shipment day — leaving it set lets the
// stranded-PAID day sweep pick the booking up and resurrect it.
scheduledDate: null,
});
await this.detachFromSchedule(booking);
this.notifyCustomer(
booking,
'Cancellation fee due',
`${booking.reference} expired unpaid while sharing a wagon with a paid booking. A cancellation fee for ${Math.ceil(Number(row.wagonsCancelled))} wagon(s) has been invoiced — settle it before booking again.`,
'Consolidated booking cancelled',
`${booking.reference} shared a wagon with a booking that was never paid, so it cannot board and is cancelled. A cancellation fee for ${Math.ceil(Number(row.wagonsCancelled))} wagon(s) has been invoiced; your paid freight is kept as credit — settle the fee and EDR staff will rebook you.`,
);
this.notifyStaff(
booking,
'Consolidation partner lapsed — paid booking cancelled',
`${booking.reference}: its consolidation partner lapsed unpaid, so the paid booking is cancelled with a cancellation fee invoice. Rebook it from its credit once the fee settles (it must pair up again).`,
);
} catch (err) {
this.logger.error(
`Consolidation-lapse fee failed for booking ${payload.expiredBookingId}: ${err instanceof Error ? err.message : String(err)}`,
`Consolidation-lapse cancellation failed for paid booking ${payload.paidBookingId}: ${err instanceof Error ? err.message : String(err)}`,
);
// A silent failure here leaves a PAID half-wagon booking boarding alone
// (BK-2026-000201: no LIVE IMPORT 20ft CANCELLATION_FEE rate — the fee
// pricing threw and the booking stayed PAID). Scream to staff so it is
// fixed and the booking cancelled by hand instead of shipping.
try {
const failed = await this.bookingsRepository.findById(
payload.paidBookingId,
);
if (failed) {
this.notifyStaff(
failed,
'Consolidation-lapse cancellation FAILED — action needed',
`${failed.reference}: its consolidation partner lapsed unpaid, but the automatic cancellation failed: ${err instanceof Error ? err.message : String(err)}. Fix the cause (usually a missing LIVE per-wagon CANCELLATION_FEE rate for this trade direction + container size), then cancel the whole booking manually so the fee is invoiced and its wagons are freed.`,
);
}
} catch {
// Notification is best-effort — the error log above already fired.
}
}
}
@@ -762,6 +801,26 @@ export class BookingWagonCancellationService {
const createDto = this.buildRebookDto(row, dto.scheduledDate, dto.containers);
// Same currency as the source booking — the credit is in it.
createDto.paymentCurrency = source.paymentCurrency ?? undefined;
// An odd-20ft credit shares a wagon again on rebook. GL picks who — never
// the auto-matcher (it could claim a partner behind GL's back), so the
// create below runs with auto-consolidation off and the chosen partner is
// linked once the booking exists and is PAID.
const oddFt20 = this.creditFt20(row) % 2 === 1;
let partner: Booking | null = null;
if (oddFt20) {
createDto.skipAutoConsolidation = true;
if (!dto.partnerBookingId) {
throw new BadRequestException(
'This credit carries an odd 20ft container — pick a consolidation partner booking to share its wagon (see the rebook-partners list).',
);
}
partner = await this.loadRebookPartner(
source,
dto.partnerBookingId,
dto.scheduledDate,
);
}
const created = await this.contractBooking.createUnderContract(
source.contractId,
createDto,
@@ -794,12 +853,19 @@ export class BookingWagonCancellationService {
`First-mile accept failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`,
);
}
try {
await this.bookingBatch.ensurePaidBookingAllocated(newBookingId);
} catch (err) {
this.logger.error(
`Allocation failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`,
);
if (partner) {
// Consolidated rebook: never allocate the half-wagon booking alone. It
// rides PAID and the batch engine settles the pair atomically once the
// partner's own invoice is paid.
await this.pairRebookedBooking(newBookingId, partner);
} else {
try {
await this.bookingBatch.ensurePaidBookingAllocated(newBookingId);
} catch (err) {
this.logger.error(
`Allocation failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
const updated = (await this.repo.update(row.id, {
@@ -817,6 +883,142 @@ export class BookingWagonCancellationService {
return { cancellation: updated, bookingId: newBookingId };
}
/** Total 20ft units the credit carries (odd ⇒ the rebook shares a wagon again). */
private creditFt20(row: BookingWagonCancellation): number {
return Object.entries(row.cancelledQuantities?.bySize ?? {})
.filter(([size]) => sizeFtOf(size) === 20)
.reduce((sum, [, qty]) => sum + Number(qty || 0), 0);
}
/**
* Partner candidates for rebooking an odd-20ft credit — what the GL rebook
* form lists. Empty when the credit is even (no shared wagon) or spent.
*/
async rebookPartnerCandidates(
cancellationId: string,
scheduledDate: string,
): Promise<
Array<{
id: string;
reference: string;
companyName: string | null;
status: string;
scheduledDate: string | null;
ft20Quantity: number;
}>
> {
const row = await this.mustFind(cancellationId);
if (row.status !== 'CREDIT_AVAILABLE') return [];
if (this.creditFt20(row) % 2 === 0) return [];
const source = await this.bookingsRepository.findById(row.bookingId);
if (!source) return [];
const rows = await this.bookingsRepository.findRebookConsolidationCandidates(
source,
new Date(scheduledDate),
);
return rows.map((b) => ({
id: b.id,
reference: b.reference,
companyName: b.company?.name ?? null,
status: b.status,
scheduledDate: b.scheduledDate ? b.scheduledDate.toISOString() : null,
ft20Quantity: (b.bookingContainers ?? [])
.filter((line) => Number(line.containerType?.sizeFt) === 20)
.reduce((sum, line) => sum + Number(line.quantity || 0), 0),
}));
}
/** The GL-picked partner, validated to actually fit the rebooked shared wagon. */
private async loadRebookPartner(
source: Booking,
partnerId: string,
scheduledDate: string,
): Promise<Booking> {
const partner = await this.bookingsRepository.findByIdWithFiles(partnerId);
if (!partner) {
throw new NotFoundException(`Partner booking ${partnerId} not found.`);
}
if (partner.consolidationPartnerId) {
throw new ConflictException(
`Booking ${partner.reference} already shares a wagon with another booking.`,
);
}
if (!['SUBMITTED', 'PENDING_CONSOLIDATION'].includes(partner.status)) {
throw new BadRequestException(
`Booking ${partner.reference} cannot be consolidated (status ${partner.status}).`,
);
}
if (
partner.originYardId !== source.originYardId ||
partner.destinationYardId !== source.destinationYardId ||
partner.tradeDirection !== source.tradeDirection
) {
throw new BadRequestException(
`Booking ${partner.reference} rides a different route/direction — it cannot share a wagon with this rebooking.`,
);
}
const eatDay = (d: Date | string) =>
new Date(d).toLocaleDateString('en-CA', { timeZone: 'Africa/Addis_Ababa' });
if (!partner.scheduledDate || eatDay(partner.scheduledDate) !== eatDay(scheduledDate)) {
throw new BadRequestException(
`Booking ${partner.reference} is not booked for ${eatDay(scheduledDate)} — a shared wagon must board one train.`,
);
}
const ft20 = (partner.bookingContainers ?? [])
.filter((line) => Number(line.containerType?.sizeFt) === 20)
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
if (ft20 % 2 !== 1) {
throw new BadRequestException(
`Booking ${partner.reference} has no odd 20ft container — nothing to consolidate.`,
);
}
return partner;
}
/**
* Link the rebooked (already PAID) booking with the GL-picked partner. A
* parked partner is resumed the way pairConsolidation would resume it —
* but only the partner: the rebooked side's PAID status must survive, so
* the link is written directly. The paired event then runs the partner's
* deferred contract finalize (invoice → pay window); the shared wagon
* boards once that invoice is paid.
*/
private async pairRebookedBooking(
newBookingId: string,
partner: Booking,
): Promise<void> {
// ponytail: validate-then-link without a row lock — a concurrent claim in
// this window loses silently; move to pairConsolidationIfUnpaired-style
// locking if it ever bites.
const fresh = await this.dataSource.getRepository(Booking).findOne({
where: { id: partner.id },
select: { id: true, consolidationPartnerId: true, status: true },
});
if (!fresh || fresh.consolidationPartnerId) {
throw new ConflictException(
`Booking ${partner.reference} was claimed by another consolidation while rebooking — pick another partner.`,
);
}
if (fresh.status === 'PENDING_CONSOLIDATION') {
await this.dataSource.getRepository(Booking).update(partner.id, {
status: partner.consolidationResumeStatus ?? 'SUBMITTED',
consolidationResumeStatus: null,
});
}
await this.bookingsRepository.linkConsolidationPartners(
newBookingId,
partner.id,
);
this.events.emit('booking.consolidation.paired', {
bookingIds: [partner.id],
});
this.notifyCustomer(
partner,
'Consolidation partner found',
`${partner.reference} now shares a wagon with a rebooked shipment. Pay your booking to board — the shared wagon ships once both halves are paid.`,
);
}
// ── History ────────────────────────────────────────────────────────────────
list(filter: WagonCancellationListFilter) {

View File

@@ -725,6 +725,30 @@ export class BookingsController {
return this.wagonCancellationService.withdraw(cancellationId);
}
@Get("wagon-cancellations/:cancellationId/rebook-partners")
@ApiOperation({
summary:
"Consolidation partner candidates for rebooking an odd-20ft credit on the given day (GL picks who shares the rebooked wagon)",
})
async listRebookPartners(
@Param("cancellationId", ParseUUIDPipe) cancellationId: string,
@Query("scheduledDate") scheduledDate: string,
@CurrentUser() user: TCurrentUser,
) {
await this.assertWagonCancellationActor(
cancellationId,
user,
FREIGHT_PERMS.bookings.wagonCancellationRebook,
);
if (!scheduledDate) {
throw new BadRequestException("scheduledDate is required.");
}
return this.wagonCancellationService.rebookPartnerCandidates(
cancellationId,
scheduledDate,
);
}
@Post("wagon-cancellations/:cancellationId/rebook")
@ApiOperation({
summary:
@@ -1633,6 +1657,23 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/clearance/draft-declaration/skip")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
"GL ET skips the draft-declaration round: no estimate is sent to the customer, the real declaration is filed directly and duty & tax passes by default",
})
async skipBookingDraftDeclaration(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingClearanceService.skipDraftDeclaration(
id,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/clearance/draft-declaration/accept")
@PortalCustomer()
@ApiOperation({

View File

@@ -377,6 +377,58 @@ export class BookingsRepository extends BaseRepository<Booking> {
});
}
/**
* Candidate partners for rebooking an odd-20ft cancellation credit: unpaired
* odd-20ft bookings on the same route/direction riding the requested day —
* SUBMITTED (committed direct booking) or parked PENDING_CONSOLIDATION.
* Unlike {@link findManualConsolidationCandidates} this is not customs-only:
* GL picks who shares the rebooked wagon whatever the contract kind.
*/
async findRebookConsolidationCandidates(
booking: Booking,
scheduledDate: Date,
limit = 50,
): Promise<Booking[]> {
const rows = await this.repository
.createQueryBuilder('b')
.leftJoinAndSelect('b.bookingContainers', 'bc')
.leftJoinAndSelect('bc.containerType', 'ct')
.leftJoinAndSelect('b.company', 'company')
.where('b.id != :bookingId', { bookingId: booking.id })
.andWhere('b.consolidationPartnerId IS NULL')
.andWhere('b.originYardId = :originYardId', {
originYardId: booking.originYardId,
})
.andWhere('b.destinationYardId = :destinationYardId', {
destinationYardId: booking.destinationYardId,
})
.andWhere('b.tradeDirection = :tradeDirection', {
tradeDirection: booking.tradeDirection,
})
.andWhere('b.status IN (:...statuses)', {
statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'],
})
// Same EAT booking day as the rebook — the pair shares one physical
// wagon, so it must board one train.
.andWhere(
`DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`,
{ bookingDate: scheduledDate },
)
.orderBy('b.createdAt', 'ASC')
.take(limit)
.getMany();
// Odd-20ft test in memory (two 20ft per wagon: odd + odd = whole wagons).
return rows.filter((row) => {
const lines = row.bookingContainers ?? [];
if (lines.length === 0) return false;
const ft20 = lines
.filter((line) => Number(line.containerType?.sizeFt) === 20)
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
return ft20 % 2 === 1;
});
}
/**
* Find another booking whose container quantity complements this one to fill whole wagon(s)
* (same route, same container type, partial wagon on both sides). Only 20ft lines ever

View File

@@ -27,11 +27,15 @@ export class PriceLineItemDto {
currency!: string;
}
/** One physical container over its per-container VGM limit. */
export class OverweightLineDto {
@ApiProperty()
containerTypeCode!: string;
@ApiProperty()
@ApiProperty({ description: 'Container number, or "<code> #2" when unnumbered' })
containerLabel!: string;
@ApiProperty({ description: "This container's VGM in tons" })
totalVgmTons!: number;
@ApiProperty()

View File

@@ -121,6 +121,15 @@ export class RebookCancelledWagonsDto {
@ValidateNested({ each: true })
@Type(() => RebookContainerLineDto)
containers?: RebookContainerLineDto[];
@ApiPropertyOptional({
description:
'Required when the credit carries an odd 20ft count: the odd-20ft booking ' +
'GL picked to share the rebooked wagon (see the rebook-partners endpoint).',
})
@IsOptional()
@IsUUID()
partnerBookingId?: string;
}
export class FilterWagonCancellationsDto {

View File

@@ -359,6 +359,12 @@ export class Booking extends BaseEntity {
@Column({ name: 'customs_clearing_agent', type: 'varchar', length: 200, nullable: true })
customsClearingAgent?: string | null;
@Column({ name: 'customs_clearing_agent_email', type: 'varchar', length: 200, nullable: true })
customsClearingAgentEmail?: string | null;
@Column({ name: 'customs_clearing_agent_phone', type: 'varchar', length: 50, nullable: true })
customsClearingAgentPhone?: string | null;
@Column({ name: 'equipment_return', type: 'varchar', length: 20 })
equipmentReturn!: string;
@@ -411,6 +417,23 @@ export class Booking extends BaseEntity {
@Column({ name: 'bulk_total_weight_tons', type: 'numeric', precision: 12, scale: 3, nullable: true })
bulkTotalWeightTons?: number | null;
/**
* NUMBER_OF_WAGONS bulk only: the wagon count the customer asked for at
* booking. Allocation and PER_WAGON pricing use this count verbatim, and the
* cargo weight spreads evenly across it (weight ÷ count per wagon — validated
* against wagon capacity at creation). Null for every other cargo unit.
*/
@Column({ name: 'bulk_requested_wagons', type: 'int', nullable: true })
bulkRequestedWagons?: number | null;
/**
* NUMBER_OF_WAGONS bulk only: optional informational item count entered with
* the weight. Never prices or sizes anything (unlike PER_ITEM, where the
* count lives in cargoTotalWeightVgm).
*/
@Column({ name: 'bulk_item_count', type: 'int', nullable: true })
bulkItemCount?: number | null;
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
isHazardous!: boolean;

View File

@@ -1,7 +1,7 @@
import { Controller, Get, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@tria-plc/api-common/modules/auth/decorators/current-user.decorator';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { FreightJwtGuard } from '../../common/freight-jwt.guard';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { ChatSync } from '../../common/booking-guards';
@@ -18,7 +18,7 @@ export class ChatController {
) {}
@Get('sso')
@UseGuards(JwtGuard)
@UseGuards(FreightJwtGuard)
@ApiOperation({ summary: 'One-click sign-in link into EDR internal chat' })
getSso(@CurrentUser() user: TCurrentUser) {
return this.sso.getSsoUrl(user);

View File

@@ -34,15 +34,27 @@ describe('bulkTemplateCode', () => {
expect(bulkTemplateCode('STEEL', 'INTERCITY', null)).toBe('BULK_INTERCITY_STEEL');
});
it('produces 5 distinct codes per cargo type', () => {
it('gives the Ethiopian-customs-only variant its own suffix', () => {
expect(bulkTemplateCode('STEEL', 'IMPORT', true, true)).toBe(
'BULK_IMPORT_STEEL_ETHIOPIAN_CUSTOMS',
);
// The flag is meaningless without customs clearing.
expect(bulkTemplateCode('STEEL', 'IMPORT', false, true)).toBe(
'BULK_IMPORT_STEEL_NO_CUSTOMS',
);
});
it('produces 7 distinct codes per cargo type', () => {
const codes = [
bulkTemplateCode('STEEL', 'IMPORT', true),
bulkTemplateCode('STEEL', 'IMPORT', true, true),
bulkTemplateCode('STEEL', 'IMPORT', false),
bulkTemplateCode('STEEL', 'EXPORT', true),
bulkTemplateCode('STEEL', 'EXPORT', true, true),
bulkTemplateCode('STEEL', 'EXPORT', false),
bulkTemplateCode('STEEL', 'INTERCITY', null),
];
expect(new Set(codes).size).toBe(5);
expect(new Set(codes).size).toBe(7);
});
});
@@ -112,6 +124,33 @@ describe('ContractTemplatesService bulk create/resolve', () => {
).rejects.toBeInstanceOf(BadRequestException);
});
it('creates the Ethiopian-customs-only variant alongside the full-customs one', async () => {
const { service } = build();
const created = await service.create({
cargoTypeId: 'cargo-1',
tradeDirection: 'IMPORT',
withCustoms: true,
ethiopianCustomsOnly: true,
});
expect(created.code).toBe('BULK_IMPORT_STEEL_ETHIOPIAN_CUSTOMS');
expect(created.ethiopianCustomsOnly).toBe(true);
expect(created.documentTitle).toBe(
'Steel Transportation and Ethiopian Customs Clearance Services',
);
});
it('rejects Ethiopian-customs-only without customs clearing', async () => {
const { service } = build();
await expect(
service.create({
cargoTypeId: 'cargo-1',
tradeDirection: 'IMPORT',
withCustoms: false,
ethiopianCustomsOnly: true,
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('resolves a domestic bulk contract to the intercity template, ignoring its customs flag', async () => {
const { repository, service } = build();
await service.findActiveForContract('DOMESTIC', 'BULK', true, 'cargo-1');
@@ -119,6 +158,7 @@ describe('ContractTemplatesService bulk create/resolve', () => {
'cargo-1',
'INTERCITY',
null,
false,
);
});
@@ -129,6 +169,18 @@ describe('ContractTemplatesService bulk create/resolve', () => {
'cargo-1',
'IMPORT',
false,
false,
);
});
it('resolves an Ethiopian-customs-only contract to the Ethiopian variant', async () => {
const { repository, service } = build();
await service.findActiveForContract('IMPORT', 'BULK', true, 'cargo-1', true);
expect(repository.findActiveBulkTemplate).toHaveBeenCalledWith(
'cargo-1',
'IMPORT',
true,
true,
);
});
});

View File

@@ -16,6 +16,22 @@ describe('contractTemplateCodeFor', () => {
);
});
it('resolves the Ethiopian variant only when customs clearing is enabled', () => {
expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', true, true)).toBe(
'IMPORT_CONTAINER_ETHIOPIAN_CUSTOMS',
);
expect(contractTemplateCodeFor('EXPORT', 'BULK', true, true)).toBe(
'EXPORT_BULK_ETHIOPIAN_CUSTOMS',
);
// Without customs clearing the Ethiopian flag is meaningless.
expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', false, true)).toBe(
'IMPORT_CONTAINER_NO_CUSTOMS',
);
expect(contractTemplateCodeFor('DOMESTIC', 'CONTAINER', true, true)).toBe(
'INTERCITY_CONTAINER',
);
});
it('never gives intercity a customs variant — it crosses no border', () => {
for (const flag of [true, false, null, undefined]) {
expect(contractTemplateCodeFor('DOMESTIC', 'BULK', flag)).toBe('INTERCITY_BULK');
@@ -40,7 +56,11 @@ describe('contractTemplateCodeFor', () => {
for (const d of directions) {
for (const f of freights) {
for (const c of [true, false]) {
expect(CONTRACT_TEMPLATE_CODES).toContain(contractTemplateCodeFor(d, f, c));
for (const e of [true, false, undefined]) {
expect(CONTRACT_TEMPLATE_CODES).toContain(
contractTemplateCodeFor(d, f, c, e),
);
}
}
}
}
@@ -48,9 +68,9 @@ describe('contractTemplateCodeFor', () => {
});
describe('CONTRACT_TEMPLATE_DEFAULTS', () => {
it('seeds exactly the ten declared codes, once each', () => {
it('seeds exactly the fourteen declared codes, once each', () => {
const seeded = CONTRACT_TEMPLATE_DEFAULTS.map((t) => t.code).sort();
expect(seeded).toHaveLength(10);
expect(seeded).toHaveLength(14);
expect(seeded).toEqual([...CONTRACT_TEMPLATE_CODES].sort());
});

View File

@@ -1,7 +1,7 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { IsNull, Repository } from "typeorm";
import { Repository } from "typeorm";
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
import {
@@ -33,10 +33,22 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
cargoTypeId: string,
tradeDirection: BulkTemplateDirection,
withCustoms: boolean | null,
ethiopianCustomsOnly = false,
): Promise<ContractTemplate | null> {
return this.repository.findOne({
where: { cargoTypeId, tradeDirection, withCustoms: withCustoms ?? IsNull() },
});
return this.repository
.createQueryBuilder("t")
.where("t.cargo_type_id = :cargoTypeId", { cargoTypeId })
.andWhere("t.trade_direction = :tradeDirection", { tradeDirection })
.andWhere(
withCustoms === null
? "t.with_customs IS NULL"
: "t.with_customs = :withCustoms",
withCustoms === null ? {} : { withCustoms },
)
.andWhere("COALESCE(t.ethiopian_customs_only, false) = :ethiopianCustomsOnly", {
ethiopianCustomsOnly,
})
.getOne();
}
/**
@@ -49,6 +61,7 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
cargoTypeId: string,
tradeDirection: BulkTemplateDirection,
withCustoms: boolean | null,
ethiopianCustomsOnly = false,
): Promise<ContractTemplate | null> {
return this.repository
.createQueryBuilder("t")
@@ -60,6 +73,9 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
: "t.with_customs = :withCustoms",
withCustoms === null ? {} : { withCustoms },
)
.andWhere("COALESCE(t.ethiopian_customs_only, false) = :ethiopianCustomsOnly", {
ethiopianCustomsOnly,
})
.andWhere(
`(t.cargo_type_id = :cargoTypeId OR t.cargo_type_id = (
SELECT c.parent_group_id FROM freight.cargo_types c

View File

@@ -41,13 +41,17 @@ import {
*/
const PREVIEW_TEMPLATE_KEYS: Record<ContractTemplateCode, string> = {
IMPORT_BULK_CUSTOMS: "IMP_BULK_USD_FORWARDING",
IMPORT_BULK_ETHIOPIAN_CUSTOMS: "IMP_BULK_USD_FORWARDING",
IMPORT_BULK_NO_CUSTOMS: "IMP_BULK_USD_TRANSPORT_ONLY",
EXPORT_BULK_CUSTOMS: "EXP_BULK_USD_FORWARDING",
EXPORT_BULK_ETHIOPIAN_CUSTOMS: "EXP_BULK_USD_FORWARDING",
EXPORT_BULK_NO_CUSTOMS: "EXP_BULK_USD_TRANSPORT_ONLY",
INTERCITY_BULK: "DOM_BULK_USD_TRANSPORT_ONLY",
IMPORT_CONTAINER_CUSTOMS: "IMP_CON_USD_FORWARDING",
IMPORT_CONTAINER_ETHIOPIAN_CUSTOMS: "IMP_CON_USD_FORWARDING",
IMPORT_CONTAINER_NO_CUSTOMS: "IMP_CON_USD_TRANSPORT_ONLY",
EXPORT_CONTAINER_CUSTOMS: "EXP_CON_USD_FORWARDING",
EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS: "EXP_CON_USD_FORWARDING",
EXPORT_CONTAINER_NO_CUSTOMS: "EXP_CON_USD_TRANSPORT_ONLY",
INTERCITY_CONTAINER: "DOM_CON_USD_TRANSPORT_ONLY",
};
@@ -101,7 +105,7 @@ export class ContractTemplatesService {
const direction = dto.tradeDirection;
const intercity = direction === "INTERCITY";
if (intercity && dto.withCustoms !== undefined) {
if (intercity && (dto.withCustoms !== undefined || dto.ethiopianCustomsOnly)) {
throw new BadRequestException(
"Intercity contracts are domestic and cross no border — they have no customs clearing variant",
);
@@ -112,12 +116,24 @@ export class ContractTemplatesService {
);
}
const withCustoms = intercity ? null : Boolean(dto.withCustoms);
const ethiopianOnly = Boolean(dto.ethiopianCustomsOnly) && !intercity;
if (ethiopianOnly && !withCustoms) {
throw new BadRequestException(
"Ethiopian-customs-only is a customs clearing variant — it requires withCustoms to be true",
);
}
const label = this.comboLabel(cargoType.cargoTypeName, direction, withCustoms);
const label = this.comboLabel(
cargoType.cargoTypeName,
direction,
withCustoms,
ethiopianOnly,
);
const existing = await this.repository.findByCargoCombo(
dto.cargoTypeId,
direction,
withCustoms,
ethiopianOnly,
);
if (existing) {
throw new ConflictException(
@@ -126,11 +142,13 @@ export class ContractTemplatesService {
}
const template = new ContractTemplate();
template.code = bulkTemplateCode(cargoType.code, direction, withCustoms);
template.code = bulkTemplateCode(cargoType.code, direction, withCustoms, ethiopianOnly);
template.name = dto.name ?? label;
template.description = dto.description ?? null;
template.documentTitle = withCustoms
? `${cargoType.cargoTypeName} Transportation and Customs Clearance Services`
? ethiopianOnly
? `${cargoType.cargoTypeName} Transportation and Ethiopian Customs Clearance Services`
: `${cargoType.cargoTypeName} Transportation and Customs Clearance Services`
: `${cargoType.cargoTypeName} Transportation Services`;
template.whereasClauses = [];
template.articles = [];
@@ -138,6 +156,7 @@ export class ContractTemplatesService {
template.cargoTypeId = cargoType.id;
template.tradeDirection = direction;
template.withCustoms = withCustoms;
template.ethiopianCustomsOnly = intercity ? null : ethiopianOnly;
template.isSystem = false;
try {
return await this.repository.saveTemplate(template);
@@ -157,18 +176,21 @@ export class ContractTemplatesService {
cargoTypeName: string,
direction: BulkTemplateDirection,
withCustoms: boolean | null,
ethiopianCustomsOnly = false,
): string {
const dir = direction.charAt(0) + direction.slice(1).toLowerCase();
const customs =
withCustoms === null
? ""
: withCustoms
? ", with customs clearing"
? ethiopianCustomsOnly
? ", with Ethiopian customs clearing only"
: ", with customs clearing"
: ", without customs clearing";
return `${cargoTypeName} Bulk Contract (${dir}${customs})`;
}
/** Bulk templates only — the five seeded container templates are permanent. */
/** Bulk templates only — the seeded container templates are permanent. */
async remove(code: string): Promise<void> {
const template = await this.getByCode(code);
if (template.isSystem) {
@@ -193,6 +215,7 @@ export class ContractTemplatesService {
freightType?: string | null,
customsClearingEnabled?: boolean | null,
cargoTypeId?: string | null,
ethiopianCustomsOnly?: boolean | null,
): Promise<ContractTemplate | null> {
const isBulk = (freightType ?? "").toUpperCase().includes("BULK");
if (isBulk) {
@@ -202,12 +225,16 @@ export class ContractTemplatesService {
cargoTypeId,
direction,
direction === "INTERCITY" ? null : Boolean(customsClearingEnabled),
direction === "INTERCITY"
? false
: Boolean(customsClearingEnabled && ethiopianCustomsOnly),
);
}
const code = contractTemplateCodeFor(
tradeDirection,
freightType,
customsClearingEnabled,
ethiopianCustomsOnly,
);
const template = await this.repository.findByCode(code);
return template?.isActive ? template : null;

View File

@@ -43,6 +43,14 @@ export class CreateContractTemplateDto {
@IsBoolean()
withCustoms?: boolean;
@ApiPropertyOptional({
description:
"Restricts the with-customs variant to Ethiopian-side clearing only (Djibouti stays with the Client). Requires withCustoms=true; rejected for INTERCITY",
})
@IsOptional()
@IsBoolean()
ethiopianCustomsOnly?: boolean;
@ApiPropertyOptional({ description: "Display name (derived from the cargo type when omitted)" })
@IsOptional()
@IsString()

View File

@@ -4,9 +4,10 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import { CargoType } from "../../rule-engine/entities/cargo-type.entity";
/**
* The five seeded container templates (import/export split by customs
* clearing; intercity is domestic, crosses no border, so it has a single
* template). These are system rows: always present, never deletable.
* The seeded container templates (import/export split by customs-clearing
* option — full, Ethiopian-only, none; intercity is domestic, crosses no
* border, so it has a single template). These are system rows: always
* present, never deletable.
*
* Bulk templates are NOT seeded — staff create them per bulk cargo type
* (`cargoTypeId`), trade direction (`tradeDirection`) and customs option
@@ -20,18 +21,24 @@ import { CargoType } from "../../rule-engine/entities/cargo-type.entity";
*
* The `_CUSTOMS` variant is issued when the contract has customs clearing
* enabled (the Service Provider clears in Djibouti/Ethiopia on the Client's
* behalf); `_NO_CUSTOMS` is the transport-only paper, where the Client handles
* behalf); `_ETHIOPIAN_CUSTOMS` when the service type is Ethiopian-customs-only
* (the Service Provider clears the Ethiopian side only, Djibouti stays with the
* Client); `_NO_CUSTOMS` is the transport-only paper, where the Client handles
* its own declarations.
*/
export const CONTRACT_TEMPLATE_CODES = [
"IMPORT_BULK_CUSTOMS",
"IMPORT_BULK_ETHIOPIAN_CUSTOMS",
"IMPORT_BULK_NO_CUSTOMS",
"EXPORT_BULK_CUSTOMS",
"EXPORT_BULK_ETHIOPIAN_CUSTOMS",
"EXPORT_BULK_NO_CUSTOMS",
"INTERCITY_BULK",
"IMPORT_CONTAINER_CUSTOMS",
"IMPORT_CONTAINER_ETHIOPIAN_CUSTOMS",
"IMPORT_CONTAINER_NO_CUSTOMS",
"EXPORT_CONTAINER_CUSTOMS",
"EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS",
"EXPORT_CONTAINER_NO_CUSTOMS",
"INTERCITY_CONTAINER",
] as const;
@@ -66,6 +73,7 @@ export function contractTemplateCodeFor(
tradeDirection?: string | null,
freightType?: string | null,
customsClearingEnabled?: boolean | null,
ethiopianCustomsOnly?: boolean | null,
): ContractTemplateCode {
const direction =
tradeDirection === "IMPORT"
@@ -78,7 +86,11 @@ export function contractTemplateCodeFor(
if (direction === "INTERCITY") {
return `INTERCITY_${freight}` as ContractTemplateCode;
}
const customs = customsClearingEnabled ? "CUSTOMS" : "NO_CUSTOMS";
const customs = customsClearingEnabled
? ethiopianCustomsOnly
? "ETHIOPIAN_CUSTOMS"
: "CUSTOMS"
: "NO_CUSTOMS";
return `${direction}_${freight}_${customs}` as ContractTemplateCode;
}
@@ -107,9 +119,16 @@ export function bulkTemplateCode(
cargoCode: string,
direction: BulkTemplateDirection,
withCustoms: boolean | null,
ethiopianCustomsOnly = false,
): string {
const suffix =
direction === "INTERCITY" ? "" : withCustoms ? "_CUSTOMS" : "_NO_CUSTOMS";
direction === "INTERCITY"
? ""
: withCustoms
? ethiopianCustomsOnly
? "_ETHIOPIAN_CUSTOMS"
: "_CUSTOMS"
: "_NO_CUSTOMS";
return `BULK_${direction}_${cargoCode}${suffix}`.toUpperCase();
}
@@ -162,7 +181,15 @@ export class ContractTemplate extends BaseEntity {
@Column({ name: "with_customs", type: "boolean", nullable: true })
withCustoms?: boolean | null;
/** The five seeded container templates — cannot be deleted. */
/**
* Bulk templates only: the with-customs variant restricted to Ethiopian-side
* clearing (Djibouti stays with the Client). Only meaningful when
* `withCustoms` is true; null/false otherwise.
*/
@Column({ name: "ethiopian_customs_only", type: "boolean", nullable: true })
ethiopianCustomsOnly?: boolean | null;
/** The seeded container templates — cannot be deleted. */
@Column({ name: "is_system", type: "boolean", default: false })
isSystem!: boolean;
}

View File

@@ -785,6 +785,50 @@ export class BookingClearanceService {
return updated;
}
/**
* GL Ethiopia skips the draft-declaration round entirely: the customer is
* not sent an estimate, staff file the real customs declaration directly.
* Duty & tax passes with it by default — there is no draft price to advise
* from. Advising duty later still works and overrides the skip (a skipped
* milestone is completed normally by adviseDuty).
*/
async skipDraftDeclaration(bookingId: string, userId?: string): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Draft declaration applies only to import bookings.');
}
await this.milestoneService.ensureBookingMilestones(bookingId, 'IMPORT');
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
const uploaded = milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED');
if (uploaded?.status === 'COMPLETED') {
throw new BadRequestException(
'A draft declaration was already sent to the customer — it can no longer be skipped.',
);
}
await this.workflowService.assertPriorCompleteForBooking(
bookingId,
'IMPORT',
'DRAFT_DECLARATION_UPLOADED',
);
await this.workflowService.skipMilestonesForBooking(bookingId, [
'DRAFT_DECLARATION_UPLOADED',
'DRAFT_DECLARATION_ACCEPTED',
]);
await this.workflowService.onDutySkippedForBooking(bookingId);
await this.bookingsRepository.update(bookingId, {
dutyRequired: false,
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'DRAFT_DECLARATION_SKIPPED',
label:
'Skipped the draft declaration — filing the customs declaration directly (duty & tax passed by default)',
actorId: userId ?? null,
});
return this.bookingsService.findById(bookingId);
}
/**
* The customer accepts the draft declaration — GL Ethiopia may now file the
* real customs declaration.

View File

@@ -227,3 +227,85 @@ describe('ContractBookingService — quantity-cap completion', () => {
});
});
});
/**
* The customer's shipment request is the order: GL may not change its container
* sizes/quantities or billing currency at completion — only per-unit details.
*/
describe('ContractBookingService — shipment-request lock at completion', () => {
type WithAssert = {
assertMatchesShipmentRequest(
bookingId: string,
dto: {
paymentCurrency?: string;
containers?: Array<{ containerSize: string; quantity: number }>;
bulkLines?: Array<{ cargoWeightTons?: number }>;
},
): Promise<void>;
};
const serviceWithRequest = (request: unknown): WithAssert => {
const svc = Object.create(ContractBookingService.prototype) as WithAssert & {
dataSource: unknown;
};
svc.dataSource = {
getRepository: () => ({ findOne: async () => request }),
};
return svc;
};
const request = {
paymentCurrency: 'USD',
requestedLines: {
containers: [
{ containerSize: '20ft', quantity: 2 },
{ containerSize: '40ft', quantity: 1 },
],
},
};
it('accepts the exact requested quantities and currency', async () => {
await expect(
serviceWithRequest(request).assertMatchesShipmentRequest('b1', {
paymentCurrency: 'USD',
containers: [
{ containerSize: '40ft', quantity: 1 },
{ containerSize: '20ft', quantity: 2 },
],
}),
).resolves.toBeUndefined();
});
it('rejects changed quantities', async () => {
await expect(
serviceWithRequest(request).assertMatchesShipmentRequest('b1', {
paymentCurrency: 'USD',
containers: [
{ containerSize: '20ft', quantity: 4 },
{ containerSize: '40ft', quantity: 1 },
],
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects a changed billing currency', async () => {
await expect(
serviceWithRequest(request).assertMatchesShipmentRequest('b1', {
paymentCurrency: 'ETB',
containers: [
{ containerSize: '20ft', quantity: 2 },
{ containerSize: '40ft', quantity: 1 },
],
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('is a no-op without a linked request', async () => {
await expect(
serviceWithRequest(null).assertMatchesShipmentRequest('b1', {
paymentCurrency: 'ETB',
containers: [{ containerSize: '20ft', quantity: 9 }],
}),
).resolves.toBeUndefined();
});
});

View File

@@ -11,6 +11,7 @@ import {
import { DataSource } from 'typeorm';
import { OnEvent } from '@nestjs/event-emitter';
import { insertWithGeneratedReference } from '@edr/api-common';
import { CargoUnitOfMeasure } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
@@ -28,6 +29,7 @@ import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-s
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { bulkTonsPerWagon } from '../train-scheduling/train-capacity.util';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
@@ -36,6 +38,7 @@ import { CargoType } from '../rule-engine/entities/cargo-type.entity';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { hasFreightPermission } from '../../common/freight-permission.util';
import { BookingRequest } from './entities/booking-request.entity';
import { Contract } from './entities/contract.entity';
import { ContractRoute } from './entities/contract-route.entity';
import {
@@ -272,6 +275,8 @@ export class ContractBookingService {
});
}
const bulkFields = await this.resolveBulkCargoFields(contract, dto);
// Denormalize route/direction/freight onto the booking for the scheduling engine.
// Retry past a concurrent insert that grabbed the same BK sequence number.
const booking = await insertWithGeneratedReference(
@@ -305,8 +310,7 @@ export class ContractBookingService {
cargoFreeText: dto.cargoFreeText?.trim() || null,
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
cargoTotalWeightVgm: this.resolveBulkTons(dto),
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
...bulkFields,
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
firstMilePickupLat: contract.firstMilePickupLat ?? null,
firstMilePickupLng: contract.firstMilePickupLng ?? null,
@@ -395,6 +399,10 @@ export class ContractBookingService {
if (
withContainers &&
freightType === 'CONTAINER' &&
// A rebooked cancellation credit carries `skipAutoConsolidation`: its
// shared-wagon partner is picked by GL in the rebook flow, so nothing may
// auto-claim (or park) it here behind GL's back.
!dto.skipAutoConsolidation &&
(await this.consolidationService.needsConsolidationFromBooking(
withContainers,
))
@@ -848,6 +856,35 @@ export class ContractBookingService {
if (!dto.scheduledDate) {
throw new BadRequestException('A binding shipment day is required');
}
// Without-customs import/export: the customer's own clearing agent (name,
// email, phone) is captured per booking at completion. A resubmit may omit
// the fields and keep what the booking already stored. Customs contracts
// (GL clears) and intercity (no border) never collect an agent.
if (
!contract.customsClearingEnabled &&
contract.tradeDirection !== 'DOMESTIC'
) {
const agentName =
dto.customsClearingAgent?.trim() || booking.customsClearingAgent || null;
const agentEmail =
dto.customsClearingAgentEmail?.trim() ||
booking.customsClearingAgentEmail ||
null;
const agentPhone =
dto.customsClearingAgentPhone?.trim() ||
booking.customsClearingAgentPhone ||
null;
if (!agentName || !agentEmail || !agentPhone) {
throw new BadRequestException(
'Customs clearing agent name, email and phone are required to complete this booking.',
);
}
await this.bookingsRepository.update(booking.id, {
customsClearingAgent: agentName,
customsClearingAgentEmail: agentEmail,
customsClearingAgentPhone: agentPhone,
} as never);
}
// No expiry gate here on purpose: this booking was already initiated
// before the contract lapsed (createUnderContract/initiateUnderContract
// already checked expiry at start). Finishing an in-flight booking must
@@ -863,6 +900,12 @@ export class ContractBookingService {
direction: contract.tradeDirection ?? null,
});
// The customer's shipment request is the order: sizes, quantities and
// billing currency are theirs — GL enters everything else. Both halves of a
// consolidated pair pass through here, so each is checked against its OWN
// request.
await this.assertMatchesShipmentRequest(booking.id, dto);
const freightType = contract.freightType;
let hasCargo =
(booking.bookingContainers?.length ?? 0) > 0 ||
@@ -944,8 +987,7 @@ export class ContractBookingService {
await this.bookingsRepository.update(booking.id, {
cargoTypeId: this.resolveCargoTypeId(contract, dto),
cargoFreeText: dto.cargoFreeText?.trim() || null,
cargoTotalWeightVgm: this.resolveBulkTons(dto),
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
...(await this.resolveBulkCargoFields(contract, dto)),
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
// Completion is where the cargo — and therefore the price — is fixed, so
// it is also where the billing currency is chosen. A bare instance was
@@ -1052,6 +1094,72 @@ export class ContractBookingService {
return { booking: completed, warnings };
}
/**
* The linked shipment request (customs Path B) is the customer's order:
* container sizes + quantities and the billing currency are the customer's
* choices, and GL may not change them at completion — only per-unit details
* (numbers, seals, VGM, handling) are GL's to enter. No linked request, or a
* legacy request without lines/currency ⇒ nothing to enforce. Container lines
* are checked only when the payload restates cargo (a day-only resubmit keeps
* the already-validated persisted cargo).
*/
private async assertMatchesShipmentRequest(
bookingId: string,
dto: CreateBookingUnderContractDto,
): Promise<void> {
const request = await this.dataSource.getRepository(BookingRequest).findOne({
where: { createdBookingId: bookingId },
});
if (!request) return;
const lines = request.requestedLines ?? {};
if (request.paymentCurrency) {
if (dto.paymentCurrency && dto.paymentCurrency !== request.paymentCurrency) {
throw new BadRequestException(
`The customer chose ${request.paymentCurrency} on the shipment request — the billing currency cannot be changed.`,
);
}
dto.paymentCurrency = request.paymentCurrency;
}
if (dto.containers?.length && lines.containers?.length) {
// Compare per size in ft ("20ft" vs "20FT"/"20" spellings must not differ).
const byFt = (rows: Array<{ containerSize: string; quantity: number }>) => {
const map = new Map<number, number>();
for (const row of rows) {
const ft = parseInt(String(row.containerSize), 10);
map.set(ft, (map.get(ft) ?? 0) + Number(row.quantity || 0));
}
return map;
};
const requested = byFt(lines.containers);
const given = byFt(dto.containers);
const same =
requested.size === given.size &&
[...requested].every(([ft, qty]) => given.get(ft) === qty);
if (!same) {
const summary = [...requested]
.map(([ft, qty]) => `${qty} × ${ft}ft`)
.join(', ');
throw new BadRequestException(
`The customer requested exactly ${summary} — container sizes and quantities cannot be changed at completion.`,
);
}
}
if (dto.bulkLines?.length && lines.bulk?.cargoWeightTons != null) {
const givenTons = dto.bulkLines.reduce(
(sum, l) => sum + Number(l.cargoWeightTons || 0),
0,
);
if (givenTons !== Number(lines.bulk.cargoWeightTons)) {
throw new BadRequestException(
`The customer requested ${lines.bulk.cargoWeightTons} tons on the shipment request — the bulk quantity cannot be changed at completion.`,
);
}
}
}
/**
* Search for a complementary partner for a parked-eligible drawdown, pair it or
* park it in PENDING_CONSOLIDATION with the resume status it should return to.
@@ -1456,8 +1564,10 @@ export class ContractBookingService {
return probe;
}
probe.cargoTotalWeightVgm = this.resolveBulkTons(dto);
probe.bulkTotalWeightTons = this.resolveBulkWeightTons(dto);
const bulkFields = await this.resolveBulkCargoFields(contract, dto);
probe.cargoTotalWeightVgm = bulkFields.cargoTotalWeightVgm;
probe.bulkTotalWeightTons = bulkFields.bulkTotalWeightTons;
probe.bulkRequestedWagons = bulkFields.bulkRequestedWagons;
const cargoTypeId = this.resolveCargoTypeId(contract, dto);
probe.cargoTypeId = cargoTypeId;
if (cargoTypeId) {
@@ -1861,6 +1971,107 @@ export class ContractBookingService {
return tons > 0 ? tons : null;
}
/**
* Bulk cargo columns for the booking row, resolved against the commodity's
* unit of measure:
*
* - PER_TON: `cargoTotalWeightVgm` = tons (legacy behaviour).
* - PER_ITEM: `cargoTotalWeightVgm` = item count, real tonnage in
* `bulkTotalWeightTons` (legacy behaviour).
* - NUMBER_OF_WAGONS: `cargoTotalWeightVgm` = tons, and the payload must fix
* the wagon count (customer on the portal, GL in the backoffice). The
* count is validated so each wagon's even share (tons ÷ wagons) fits what
* one wagon of this cargo may carry; the optional item count is stored as
* information only and never prices or sizes anything.
*
* Container contracts (and payloads without bulk lines) pass through with
* the legacy zero/null values.
*/
private async resolveBulkCargoFields(
contract: Contract,
dto: CreateBookingUnderContractDto,
): Promise<{
cargoTotalWeightVgm: number;
bulkTotalWeightTons: number | null;
bulkRequestedWagons: number | null;
bulkItemCount: number | null;
}> {
const legacy = {
cargoTotalWeightVgm: this.resolveBulkTons(dto),
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
bulkRequestedWagons: null as number | null,
bulkItemCount: null as number | null,
};
if (contract.freightType === 'CONTAINER' || !dto.bulkLines?.length) {
return legacy;
}
const cargoTypeId = this.resolveCargoTypeId(contract, dto);
if (!cargoTypeId) return legacy;
const cargoType = await this.dataSource.getRepository(CargoType).findOne({
where: { id: cargoTypeId },
relations: { wagonTypes: true },
});
if (cargoType?.unitOfMeasure !== CargoUnitOfMeasure.NumberOfWagons) {
return legacy;
}
const tons = dto.bulkLines.reduce(
(sum, l) => sum + Number(l.cargoWeightTons ?? 0),
0,
);
const items = dto.bulkLines.reduce(
(sum, l) => sum + Number(l.itemCount ?? 0),
0,
);
const wagons = Math.floor(Number(dto.requestedWagons ?? 0));
if (!(wagons >= 1)) {
throw new BadRequestException(
`${cargoType.cargoTypeName} is booked by wagons — enter the number of wagons needed.`,
);
}
if (!(tons > 0)) {
throw new BadRequestException('Cargo weight in tons is required.');
}
this.assertWagonShareFits(cargoType, tons, wagons);
return {
cargoTotalWeightVgm: tons,
bulkTotalWeightTons: null,
bulkRequestedWagons: wagons,
bulkItemCount: items > 0 ? Math.floor(items) : null,
};
}
/**
* NUMBER_OF_WAGONS: block the booking outright when the even per-wagon share
* (tons ÷ requested wagons) is heavier than what ANY of the cargo's allowed
* wagon types may carry — 100T on 2 wagons is 50T each and fine on a 60T
* wagon, but 100T on 1 wagon can never ride. Cargo types with no wagon types
* configured skip the check (allocation falls back to the default rating).
*/
private assertWagonShareFits(
cargoType: CargoType,
tons: number,
wagons: number,
): void {
const allowed = (cargoType.wagonTypes ?? []).filter(
(wt) => Number(wt.capacityTons) > 0,
);
if (!allowed.length) return;
const maxPerWagon = Math.max(
...allowed.map((wt) =>
bulkTonsPerWagon(cargoType, wt.id, Number(wt.capacityTons)),
),
);
const share = tons / wagons;
if (share > maxPerWagon) {
throw new BadRequestException(
`${tons} tons across ${wagons} wagon(s) loads ${round3(share)}T per wagon, ` +
`but a wagon of this cargo carries at most ${round3(maxPerWagon)}T — ` +
`request at least ${Math.ceil(tons / maxPerWagon)} wagons.`,
);
}
}
/**
* Per-line handling counts. Each physical container carries its own hazardous
* / reefer / return switch (entered next to its VGM), so the count is however
@@ -2091,6 +2302,7 @@ export class ContractBookingService {
): Promise<{
overweightLines: Array<{
containerTypeCode: string;
containerLabel: string;
totalVgmTons: number;
maxAllowedTons: number;
excessTons: number;
@@ -2183,8 +2395,7 @@ export class ContractBookingService {
contractRouteId: route?.id ?? null,
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
cargoTotalWeightVgm: this.resolveBulkTons(dto),
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
...(await this.resolveBulkCargoFields(contract, dto)),
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
bookingContainers: resolved.map(({ line, ct, totalVgmTons }) =>
@@ -2200,6 +2411,12 @@ export class ContractBookingService {
: 0,
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
totalVgmTons,
// Per-box weights drive the overweight check — the limit is per
// container, so a heavy box is billed even when the line total fits.
units: (line.units ?? []).map((u, idx) => ({
vgmTons: Number(u.vgmTons ?? 0),
sortOrder: idx,
})) as BookingContainer['units'],
wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt)),
}),
),
@@ -2233,6 +2450,7 @@ export class ContractBookingService {
containerTypeId: ct.id,
quantity: line.quantity,
totalVgmTons,
unitVgmTons: (line.units ?? []).map((u) => Number(u.vgmTons ?? 0)),
})),
contract.tradeDirection,
);
@@ -2312,7 +2530,12 @@ export class ContractBookingService {
(s, u) => s + Number(u.vgmTons ?? 0),
0,
);
return { containerTypeId: ct.id, quantity: line.quantity, totalVgmTons };
return {
containerTypeId: ct.id,
quantity: line.quantity,
totalVgmTons,
unitVgmTons: (line.units ?? []).map((u) => Number(u.vgmTons ?? 0)),
};
}),
);

View File

@@ -14,6 +14,8 @@ import {
type ClearanceTrainState,
} from '@edr/types';
import { DataSource } from 'typeorm';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
import { FilesService } from '../files/files.service';
@@ -117,6 +119,18 @@ export interface ContractClearanceView {
linkedBookingReviewNote?: string | null;
/** Shipment day the booking currently holds — the default when GL resubmits. */
linkedBookingScheduledDate?: string | null;
/**
* Open wagon-cancellation on a CANCELLED linked booking (consolidation
* partner lapsed, staff cut): FEE_PENDING = customer must pay the
* cancellation fee; CREDIT_AVAILABLE = fee settled, GL rebooks the credit.
*/
linkedBookingCancellation?: {
id: string;
status: string;
wagonsCancelled: number;
creditAmount: number;
creditCurrency: string;
} | null;
dutyAdvice?: {
amount: number;
currency: string;
@@ -171,6 +185,7 @@ export class ContractClearanceService {
private readonly glOperationsService: GlOperationsService,
private readonly notifier: ContractNotifierService,
private readonly transitAgentsService: TransitAgentsService,
private readonly dataSource: DataSource,
) {}
private isPhasedCustoms(contract: Contract): boolean {
@@ -369,9 +384,27 @@ export class ContractClearanceService {
// without a cycle row), so fall back to the contract's own live booking —
// otherwise the clearance page sees no linked booking at all and cannot show
// its status or the actions that depend on it.
const booking = cycle?.bookingId
let booking = cycle?.bookingId
? await this.bookingsService.findById(cycle.bookingId)
: await this.contractsRepository.findLatestBookingForContract(contractId);
// The fallback skips terminal bookings, but a CANCELLED one with an open
// wagon-cancellation still belongs on this page: the fee gate and the
// rebook-from-credit action live here. Surface the newest such booking.
if (!booking) {
const [open] = await this.dataSource.query<{ booking_id: string }[]>(
`SELECT c.booking_id
FROM freight.booking_wagon_cancellations c
JOIN freight.bookings b ON b.id = c.booking_id
WHERE b.contract_id = $1
AND b.status = 'CANCELLED'
AND c.status IN ('FEE_PENDING', 'CREDIT_AVAILABLE')
AND c.deleted_at IS NULL
ORDER BY c.created_at DESC
LIMIT 1`,
[contractId],
);
if (open) booking = await this.bookingsService.findById(open.booking_id);
}
if (booking) {
linkedBookingId = booking.id ?? null;
linkedBookingReference = booking.reference ?? null;
@@ -395,6 +428,40 @@ export class ContractClearanceService {
}
}
// A CANCELLED booking may carry an open wagon-cancellation (consolidation
// partner lapsed, staff cut): FEE_PENDING gates on the customer paying the
// cancellation fee; CREDIT_AVAILABLE lets GL rebook from the credit here.
let linkedBookingCancellation: {
id: string;
status: string;
wagonsCancelled: number;
creditAmount: number;
creditCurrency: string;
} | null = null;
if (booking && linkedBookingStatus === 'CANCELLED') {
const [row] = await this.dataSource.query<
{ id: string; status: string; wagons_cancelled: string; credit_amount: string }[]
>(
`SELECT id, status, wagons_cancelled, credit_amount
FROM freight.booking_wagon_cancellations
WHERE booking_id = $1
AND status IN ('FEE_PENDING', 'CREDIT_AVAILABLE')
AND deleted_at IS NULL
ORDER BY created_at DESC
LIMIT 1`,
[booking.id],
);
if (row) {
linkedBookingCancellation = {
id: row.id,
status: row.status,
wagonsCancelled: Number(row.wagons_cancelled),
creditAmount: Number(row.credit_amount),
creditCurrency: booking.paymentCurrency ?? 'ETB',
};
}
}
return {
contractId,
status: contract.status,
@@ -433,6 +500,7 @@ export class ContractClearanceService {
linkedBookingStatus,
linkedBookingReviewNote,
linkedBookingScheduledDate,
linkedBookingCancellation,
dutyAdvice,
dutyDispute,
transitAssignee,

View File

@@ -63,6 +63,7 @@ describe('ContractClearanceService — duty dispute', () => {
{} as never, // glOperationsService
notifier as never,
{} as never, // transitAgentsService
{} as never, // dataSource
);
build([
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),

View File

@@ -428,6 +428,8 @@ export class ContractTransitionService {
contract.customsClearingEnabled,
// Bulk templates are keyed by the contract's cargo type.
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
// Ethiopian-customs-only service types resolve to the Ethiopian variant.
contract.serviceType?.includesEthiopianCustomsOnly,
);
if (!active) return null;
return {

View File

@@ -4,6 +4,7 @@ import {
IsArray,
IsBoolean,
IsDateString,
IsEmail,
IsIn,
IsInt,
IsNumber,
@@ -11,6 +12,7 @@ import {
IsString,
IsUUID,
Matches,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
@@ -208,6 +210,19 @@ export class CreateBookingUnderContractDto {
@Type(() => CreateBulkLineDto)
bulkLines?: CreateBulkLineDto[];
@ApiPropertyOptional({
minimum: 1,
description:
'NUMBER_OF_WAGONS bulk cargo only: how many wagons the shipment needs. ' +
'The weight spreads evenly across them; a PER_WAGON rate bills this count. ' +
'Required when the cargo type is measured by wagons, ignored otherwise.',
})
@IsOptional()
@IsInt()
@Min(1)
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
requestedWagons?: number;
@ApiPropertyOptional({
description: 'What the containers carry — captured per booking (container freight).',
})
@@ -215,6 +230,29 @@ export class CreateBookingUnderContractDto {
@IsString()
cargoFreeText?: string;
@ApiPropertyOptional({
maxLength: 200,
description:
'Customs clearing agent name. Required at completion of a without-customs ' +
'import/export booking (the service enforces it); ignored on customs contracts.',
})
@IsOptional()
@IsString()
@MaxLength(200)
customsClearingAgent?: string;
@ApiPropertyOptional({ maxLength: 200, description: 'Customs clearing agent email.' })
@IsOptional()
@IsEmail()
@MaxLength(200)
customsClearingAgentEmail?: string;
@ApiPropertyOptional({ maxLength: 50, description: 'Customs clearing agent phone number.' })
@IsOptional()
@IsString()
@MaxLength(50)
customsClearingAgentPhone?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()

View File

@@ -63,6 +63,12 @@ describe('shipment preview / created booking parity', () => {
resolveShipmentEquipmentReturn: () => c.equipmentReturn,
resolveBulkTons: () => 0,
resolveBulkWeightTons: () => 0,
resolveBulkCargoFields: async () => ({
cargoTotalWeightVgm: 0,
bulkTotalWeightTons: null,
bulkRequestedWagons: null,
bulkItemCount: null,
}),
resolveContainerTypeForSize: async () => ({ id: 'ct40', sizeFt: 40 }),
handlingCounts: () => ({
hazardousQuantity: 0,

View File

@@ -61,6 +61,7 @@ describe('ContractClearanceService — transit assignee', () => {
{} as never,
notifier as never,
transitAgentsService as never,
{} as never, // dataSource
);
});

View File

@@ -1,4 +1,4 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsNumber, IsOptional, IsString, Length } from "class-validator";
import { EIMS_MODE_OF_PAYMENT, EimsModeOfPayment } from "../eims-receipt.types";
@@ -9,9 +9,15 @@ import { EIMS_MODE_OF_PAYMENT, EimsModeOfPayment } from "../eims-receipt.types";
* guessed (payment method, collector, provider references — none of it is modelled on `Invoice`).
*/
export class RegisterSalesReceiptDto {
@ApiProperty({ enum: EIMS_MODE_OF_PAYMENT, description: "MoR's confirmed ModeOfPayment enum." })
@ApiPropertyOptional({
enum: EIMS_MODE_OF_PAYMENT,
description:
"MoR's confirmed ModeOfPayment enum. Optional when the invoice's recorded payment method " +
"maps unambiguously (CASH, CHEQUE, CPO, CARD, BANK_TRANSFER); otherwise required.",
})
@IsOptional()
@IsIn(EIMS_MODE_OF_PAYMENT)
modeOfPayment!: EimsModeOfPayment;
modeOfPayment?: EimsModeOfPayment;
@ApiPropertyOptional({ description: 'Defaults to "Payment received".' })
@IsOptional()

View File

@@ -36,9 +36,12 @@ const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
tin: "0999930000",
vatNumber: "123475885858",
phone: "0912345678",
region: "13",
zone: "SHA",
woreda: "574",
// A real MoR address — Ethiopia / SOMALI / FAAFAN ZONE / JIJIGA, which the Ministry master
// codes as 70 / 6 / 31 / 190. Spelled the way EDR and e-Trade actually store it, so the
// alias layer is exercised end to end rather than only in the resolver's own spec.
region: "Somali",
zone: "Fafen",
woreda: "Jigjiga",
kebele: "03",
houseNo: "NEW",
country: "Ethiopia",

View File

@@ -7,6 +7,7 @@ import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialE
import { EimsConfig } from "../../config/eims.config";
import { Invoice } from "../billing/entities/invoice.entity";
import { MorGeoCodes, resolveMorGeo } from "../../config/mor-location.resolver";
import { EimsDocumentType, EimsMapperLine, toEimsInvoice } from "../billing/eims-invoice.mapper";
import { sendCompanyChannels } from "../notifications/notify-company.util";
import { NotificationsService } from "../notifications/notifications.service";
@@ -32,6 +33,8 @@ interface BulkReservation {
invoice: Invoice & { lines: EimsMapperLine[] };
documentType: EimsDocumentType;
relatedDocument: string | null;
/** Resolved before this reservation existed — see the `prepared` pass in `bulkRegister`. */
buyerGeo: MorGeoCodes;
invoiceCounter: number;
documentNumber: string;
previousIrn: string;
@@ -123,7 +126,16 @@ export class EimsBulkRegistrationService {
}
relatedDocument = invoice.relatedInvoice.eimsIrn;
}
return { invoice, documentType, relatedDocument };
// Same rule as the single-invoice path: buyer geography is resolved from the MoR location
// master before reserveBulk touches a counter, so one bad company address fails the whole
// batch locally instead of burning a block of EIMS sequence numbers.
const buyerGeo = resolveMorGeo({
country: invoice.company?.country,
region: invoice.company?.region,
zone: invoice.company?.zone,
woreda: invoice.company?.woreda,
});
return { invoice, documentType, relatedDocument, buyerGeo };
});
if (prepared.length === 0) {
@@ -141,6 +153,7 @@ export class EimsBulkRegistrationService {
r.invoice,
this.sellerCache.getSellerDetails(cfg),
buildEimsContext(cfg, {
buyerGeo: r.buyerGeo,
documentNumber: r.documentNumber,
invoiceCounter: r.invoiceCounter,
previousIrn: r.previousIrn,
@@ -269,7 +282,12 @@ export class EimsBulkRegistrationService {
/** TX1. Reserve a contiguous block of N counters, one per invoice, in the given order. */
private async reserveBulk(
prepared: Array<{ invoice: Invoice & { lines: EimsMapperLine[] }; documentType: EimsDocumentType; relatedDocument: string | null }>,
prepared: Array<{
invoice: Invoice & { lines: EimsMapperLine[] };
documentType: EimsDocumentType;
relatedDocument: string | null;
buyerGeo: MorGeoCodes;
}>,
systemNumber: string,
placeholder: string,
): Promise<BulkReservation[]> {
@@ -302,7 +320,7 @@ export class EimsBulkRegistrationService {
// Locked in the caller's given order — stable, avoids two concurrent bulk calls deadlocking
// on the opposite lock order.
for (const { invoice, documentType, relatedDocument } of prepared) {
for (const { invoice, documentType, relatedDocument, buyerGeo } of prepared) {
const locked = await this.lockInvoice(manager, invoice.id);
const thisCounter = counter++;
const thisDocNumber = String(docNumber++);
@@ -322,6 +340,7 @@ export class EimsBulkRegistrationService {
invoice: Object.assign(locked, { lines: invoice.lines }),
documentType,
relatedDocument,
buyerGeo,
invoiceCounter: thisCounter,
documentNumber: thisDocNumber,
previousIrn: thisPreviousIrn,

View File

@@ -66,7 +66,13 @@ describe("assertEimsInvoiceConfig — charge-type overrides", () => {
});
describe("buildEimsContext — taxForLine", () => {
const input = { documentNumber: "24", invoiceCounter: 7, previousIrn: "", session: SESSION };
const input = {
buyerGeo: { Country: "70", Region: "6", City: "31", Wereda: "190" },
documentNumber: "24",
invoiceCounter: 7,
previousIrn: "",
session: SESSION,
};
const line = (chargeType: string) => ({ chargeType, quantity: 1, unitRate: 100, amount: 100 });
it("uses the per-chargeType override when one is configured", () => {

View File

@@ -1,5 +1,6 @@
import { BadRequestException } from "@nestjs/common";
import { EimsConfig } from "../../config/eims.config";
import { MorGeoCodes } from "../../config/mor-location.resolver";
import { EimsSessionContext } from "./eims-auth.service";
import {
EimsMapperContext,
@@ -149,6 +150,12 @@ export function buildEimsSeller(config: EimsConfig): EimsSellerDetails {
}
export interface EimsContextInput {
/**
* The buyer's MoR location codes, resolved from the Ministry location master by
* `resolveMorGeo` **before** the caller reserved an EIMS counter — see
* `EimsMapperContext.buyerGeo`.
*/
buyerGeo: MorGeoCodes;
/** `DocumentDetails.DocumentNumber`. The caller decides its source. */
documentNumber: string;
invoiceCounter: number;
@@ -205,11 +212,7 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
unitDefault: invoice.unitDefault,
incomeWithholdValue: invoice.incomeWithholdValue!,
transactionWithholdValue: invoice.transactionWithholdValue!,
buyerCountryCode: invoice.buyerCountryCode,
buyerCountryCodes: invoice.buyerCountryCodes,
buyerRegionCodes: invoice.buyerRegionCodes,
buyerWeredaCodes: invoice.buyerWeredaCodes,
buyerCityCodes: invoice.buyerCityCodes,
buyerGeo: input.buyerGeo,
// TEMPORARY — see EimsInvoiceConfig.buyerIdType.
buyerIdType: invoice.buyerIdType,
buyerIdNumber: invoice.buyerIdNumber,

View File

@@ -13,6 +13,7 @@ import { EimsClientService } from "./eims-client.service";
import { EimsApiException, EimsConfigException } from "./eims.errors";
import { buildEimsSeller } from "./eims-invoice-context";
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
import { ETradeService } from "../companies/services/etrade.service";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
import { EimsSystemState } from "./entities/eims-system-state.entity";
import { EimsInvoiceStatus } from "./eims-registration.types";
@@ -58,9 +59,12 @@ const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
vatNumber: "123475885858",
phone: "0912345678",
email: "buyer@abc.et",
region: "13",
zone: "SHA",
woreda: "574",
// A real MoR address — Ethiopia / SOMALI / FAAFAN ZONE / JIJIGA, which the Ministry master
// codes as 70 / 6 / 31 / 190. Spelled the way EDR and e-Trade actually store it, so the
// alias layer is exercised end to end rather than only in the resolver's own spec.
region: "Somali",
zone: "Fafen",
woreda: "Jigjiga",
kebele: "03",
houseNo: "NEW",
country: "Ethiopia",
@@ -502,21 +506,23 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
});
});
it("a mapper failure after reservation (e.g. unmapped buyer country) also releases the reservation", async () => {
it("a mapper failure after reservation still releases the reservation", async () => {
// Regression: toEimsInvoice/buildEimsContext used to sit outside the try/catch that calls
// settleFailure — a throw here left the reservation permanently orphaned (a real live incident:
// 500 on register, then every subsequent attempt 409'd "already in flight" until manually
// resolved). This never reaches postSigned at all — the mapper throws before submit() is called.
const db = new FakeDb([
invoiceRow({ company: { ...invoiceRow().company, country: "France" } as never }),
]);
//
// The trigger used to be an unmapped buyer country. That can no longer get this far: geography
// is resolved before the reservation now (see the test below). A line/total mismatch is a
// mapper-only failure that still reaches this point.
const db = new FakeDb([invoiceRow({ totalAmount: 999999 })]);
const postSigned = jest.fn();
// The mapper throws a plain Error (it's a pure function, not a NestJS layer) — that's the
// point: settleFailure must treat *any* non-EimsApiException as pre-wire, not just its own
// known exception types.
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow(
/no MoR country code mapping/,
/lines sum to/,
);
expect(postSigned).not.toHaveBeenCalled();
@@ -532,6 +538,69 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
});
});
it("an unmappable buyer address fails before a counter is ever reserved", async () => {
// The whole point of resolving geography ahead of reserve(): a company-record problem is a
// local data problem, and it must not cost an EIMS sequence number. Nothing about the invoice
// or the system state may change.
const db = new FakeDb([
invoiceRow({ company: { ...invoiceRow().company, woreda: "Nowhere" } as never }),
]);
const before = { ...db.state };
const postSigned = jest.fn();
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow(
/no MoR LOCALITY_DESC match/,
);
expect(postSigned).not.toHaveBeenCalled();
expect(db.state).toMatchObject({
nextInvoiceCounter: before.nextInvoiceCounter,
nextDocumentNumber: before.nextDocumentNumber,
inFlightInvoiceId: null,
});
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
eimsStatus: EimsInvoiceStatus.NotSubmitted,
eimsInvoiceCounter: null,
});
});
it("files the buyer's MoR codes, resolved from the location master with no e-Trade call", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "IRN-1" } });
// The real seller cache, wired to an e-Trade mock that must never be reached: registration
// reads the company row EDR already stored, so filing stays deterministic and independent of
// e-Trade's availability. `refresh()` is deliberately not called — the cache stays empty and
// the seller falls back to static config, exactly as it does on a cold process.
const cfg = config();
const resolveCompanyData = jest.fn();
const sellerCache = new EimsSellerCacheService(
{ resolveCompanyData, extractRegistrationData: jest.fn() } as unknown as ETradeService,
{ get: () => cfg } as unknown as ConfigService,
);
const service = new EimsInvoiceRegistrationService(
db.asDataSource(),
{ get: () => cfg } as unknown as ConfigService,
{ postSigned, postBearer: jest.fn() } as unknown as EimsClientService,
{ getSessionContext: jest.fn().mockResolvedValue(SESSION) } as unknown as EimsAuthService,
{ notify: jest.fn().mockResolvedValue(undefined) } as unknown as NotificationInboxService,
{ directSend: jest.fn().mockResolvedValue(undefined) } as unknown as NotificationsService,
sellerCache,
);
await service.registerInvoiceWithEims(INVOICE_ID);
const [, body] = postSigned.mock.calls[0];
expect(body.BuyerDetails).toMatchObject({
Country: "70",
Region: "6",
City: "31",
Wereda: "190",
});
expect(resolveCompanyData).not.toHaveBeenCalled();
});
it("treats a success response with no IRN as a failed registration", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "" } });

View File

@@ -29,6 +29,7 @@ import { EimsClientService } from "./eims-client.service";
import { EimsApiException, EimsConfigException } from "./eims.errors";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
import { EimsSystemState } from "./entities/eims-system-state.entity";
import { resolveMorGeo } from "../../config/mor-location.resolver";
import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context";
import {
EimsInvoiceError,
@@ -116,6 +117,17 @@ export class EimsInvoiceRegistrationService {
relatedDocument = invoice.relatedInvoice.eimsIrn;
}
// Buyer geography is resolved from the MoR location master *here*, ahead of the reservation:
// an unknown or ambiguous company address is a local data problem, and failing it after
// reserving would consume an EIMS sequence number for an invoice that was never filable. It
// needs no network access, so there is no reason for it to sit behind the login either.
const buyerGeo = resolveMorGeo({
country: invoice.company?.country,
region: invoice.company?.region,
zone: invoice.company?.zone,
woreda: invoice.company?.woreda,
});
// Authenticate before reserving: the source system comes from the token, and the state row is
// keyed by it. A login failure here costs nothing — no counter has been consumed yet.
const session = await this.auth.getSessionContext();
@@ -135,6 +147,7 @@ export class EimsInvoiceRegistrationService {
invoice,
this.sellerCache.getSellerDetails(cfg),
buildEimsContext(cfg, {
buyerGeo,
// Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber
// against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy.
documentNumber: reservation.documentNumber,

View File

@@ -116,6 +116,47 @@ describe("EimsReceiptService.registerSalesReceipt", () => {
expect(receipt.qr).toBe("iVBORw0KGgo...");
});
it("derives mode/date/voucher/amount from the recorded manual payment", async () => {
const db = new FakeDb([
invoiceRow({
payments: [
{ amount: 4000, method: "CASH", reference: "CRV-000123", paidAt: "2026-08-20T09:00:00.000Z", metadata: null },
],
} as never),
]);
const postBearer = jest.fn().mockResolvedValue(okResponse());
await build(db, postBearer).registerSalesReceipt(INVOICE_ID, {} as never);
const request = postBearer.mock.calls[0][1];
expect(request.TransactionDetails.ModeOfPayment).toBe("CASH");
expect(request.ManualReceiptNumber).toBe("CRV-000123");
expect(request.ReceiptDate).toBe("2026-08-20T09:00:00.000Z");
expect(request.CollectedAmount).toBe(4000);
});
it("puts a gateway reference in TransactionNumber, never ManualReceiptNumber, and demands an explicit mode", async () => {
const db = new FakeDb([
invoiceRow({
payments: [
{ amount: 10000, method: "GATEWAY", reference: "txn-9f8e7d", paidAt: "2026-08-21T10:00:00.000Z", metadata: null },
],
} as never),
]);
const postBearer = jest.fn().mockResolvedValue(okResponse());
const service = build(db, postBearer);
// GATEWAY says nothing about the channel — deriving would guess a tax field.
await expect(service.registerSalesReceipt(INVOICE_ID, {} as never)).rejects.toThrow(
BadRequestException,
);
await service.registerSalesReceipt(INVOICE_ID, { modeOfPayment: "Card" } as never);
const request = postBearer.mock.calls[0][1];
expect(request.TransactionDetails.TransactionNumber).toBe("txn-9f8e7d");
expect(request.ManualReceiptNumber).not.toBe("txn-9f8e7d");
});
it("defaults PaymentCoverage to FULL when the invoice balance is 0, PARTIAL otherwise", async () => {
const db = new FakeDb([invoiceRow({ balanceAmount: 500 })]);
const postBearer = jest.fn().mockResolvedValue(okResponse());

View File

@@ -17,6 +17,8 @@ import { EimsReceipt, EimsReceiptKind, EimsReceiptStatus } from "./entities/eims
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
import {
EIMS_MODE_OF_PAYMENT,
EimsModeOfPayment,
EimsReceiptResponse,
EimsSalesReceiptRequest,
EimsWithholdReceiptRequest,
@@ -41,8 +43,11 @@ const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AU
* double-submission guard the way `/v1/cancel` does ("IRN already Canceled."), so the same caution
* applies as an unacknowledged registration: a human must check the MoR portal first.
*
* Several request fields have no confirmed source in this codebase (payment method, collector,
* withholding rate/amount) and are never guessed — see the two DTOs.
* Sales receipts derive what the invoice's payment ledger actually records — amount, date,
* finance's voucher number (ManualReceiptNumber), gateway transaction id, and the payment mode
* where the ledger method maps unambiguously to MoR's enum. Fields with no recorded source
* (collector, withholding rate/amount, mobile-money modes) are still asked of the caller, never
* guessed — see the two DTOs.
*/
@Injectable()
export class EimsReceiptService {
@@ -68,7 +73,26 @@ export class EimsReceiptService {
const session = await this.auth.getSessionContext();
const receiptNumber = this.generateReceiptNumber(invoice);
const collectedAmount = dto.collectedAmount ?? Number(invoice.paidAmount);
// The newest ledger entry is the payment this receipt vouches for. A gateway settlement's
// `reference` is the provider transaction id; a manual settlement's `reference` is finance's
// own voucher number (CRV) — that one belongs in ManualReceiptNumber so the registered
// receipt matches finance's books.
const lastPayment = invoice.payments?.length
? invoice.payments[invoice.payments.length - 1]
: null;
const isGateway = (lastPayment?.method ?? "").toUpperCase() === "GATEWAY";
const modeOfPayment = dto.modeOfPayment ?? deriveModeOfPayment(lastPayment?.method);
if (!modeOfPayment) {
throw new BadRequestException({
code: "EIMS_MODE_OF_PAYMENT_REQUIRED",
message:
`Recorded payment method "${lastPayment?.method ?? "none"}" has no unambiguous MoR ` +
`ModeOfPayment — pass modeOfPayment (one of ${EIMS_MODE_OF_PAYMENT.join(", ")}).`,
});
}
const collectedAmount =
dto.collectedAmount ?? (lastPayment ? lastPayment.amount : Number(invoice.paidAmount));
const balance = Number(invoice.balanceAmount);
const request: EimsSalesReceiptRequest = {
@@ -77,9 +101,9 @@ export class EimsReceiptService {
Reason: dto.reason ?? "Payment received",
// ISO-8601 UTC — the collection's saved example uses a "+03:00" offset instead; no schema
// error for this field was ever observed to confirm which form MoR actually requires.
ReceiptDate: new Date().toISOString(),
ReceiptDate: lastPayment?.paidAt ?? new Date().toISOString(),
ReceiptCounter: String(Date.now()),
ManualReceiptNumber: receiptNumber,
ManualReceiptNumber: (!isGateway && lastPayment?.reference) || receiptNumber,
SourceSystemType: session.systemType,
SourceSystemNumber: session.systemNumber,
ReceiptCurrency: currency,
@@ -97,7 +121,7 @@ export class EimsReceiptService {
},
],
TransactionDetails: {
ModeOfPayment: dto.modeOfPayment,
ModeOfPayment: modeOfPayment,
ChequeNumber: dto.chequeNumber ?? null,
CPONumber: dto.cpoNumber ?? null,
DocumentNumber: dto.documentNumber ?? null,
@@ -105,7 +129,7 @@ export class EimsReceiptService {
PaymentServiceProvider: dto.paymentServiceProvider ?? null,
OtherPaymentServiceProviderName: dto.otherPaymentServiceProviderName ?? null,
AccountNumber: dto.accountNumber ?? null,
TransactionNumber: dto.transactionNumber ?? null,
TransactionNumber: dto.transactionNumber ?? (isGateway ? (lastPayment?.reference ?? null) : null),
},
};
@@ -286,3 +310,17 @@ export class EimsReceiptService {
return `REC-${invoice.invoiceNumber}-${Date.now()}`;
}
}
/**
* Recorded ledger method → MoR ModeOfPayment, only where the mapping is unambiguous. Mobile-money
* methods (TELEBIRR, EBIRR, …) have no MoR enum slot, and "GATEWAY" says nothing about the real
* channel — those return undefined and the caller must supply modeOfPayment explicitly. Guessing
* a tax field is worse than asking.
*/
function deriveModeOfPayment(method: string | null | undefined): EimsModeOfPayment | undefined {
if (!method) return undefined;
const normalized = method.toUpperCase().replace(/-/g, "_");
const direct = EIMS_MODE_OF_PAYMENT.find((m) => m.toUpperCase().replace(/ /g, "_") === normalized);
if (direct) return direct;
return normalized === "BANK_TRANSFER" ? "Local Bank Transfer" : undefined;
}

View File

@@ -5,11 +5,13 @@ import { ETradeService } from "../companies/services/etrade.service";
import { eimsConfig, eimsInvoiceConfig } from "./eims-test-fixtures";
import { EimsSellerCacheService } from "./eims-seller-cache.service";
// A real MoR address (PARISH_NO 13 / CITY_NO 78 / LOCALITY_NO 1100) — the resolver now works off
// the Ministry's own hierarchy, so a made-up address would simply not resolve.
const registrationData = (over: Record<string, unknown> = {}) => ({
companyName: "Ethio-Djibouti Railway PLC (eTrade)",
region: "Addis Ababa",
zone: "Bole",
woreda: "Yeka",
woreda: "Woreda 1",
mobilePhone: "0911000000",
regularPhone: "",
...over,
@@ -24,16 +26,10 @@ const build = (cfg: EimsConfig = eimsConfig()) => {
return { service, resolveCompanyData, extractRegistrationData, cfg };
};
const CODES = {
buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: { Yeka: "99" },
buyerCityCodes: { Bole: "101" },
};
describe("EimsSellerCacheService.getSellerDetails", () => {
it("static config wins over a conflicting e-Trade value", async () => {
const cfg = eimsConfig({
invoice: eimsInvoiceConfig({ sellerLegalName: "Ethio-Djibouti Railway S.C.", ...CODES }),
invoice: eimsInvoiceConfig({ sellerLegalName: "Ethio-Djibouti Railway S.C." }),
});
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValue({
@@ -56,7 +52,6 @@ describe("EimsSellerCacheService.getSellerDetails", () => {
sellerRegion: "",
sellerWereda: "",
sellerCity: null,
...CODES,
}),
});
const { service, resolveCompanyData } = build(cfg);
@@ -67,13 +62,32 @@ describe("EimsSellerCacheService.getSellerDetails", () => {
expect(seller.LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
expect(seller.Region).toBe("13");
expect(seller.Wereda).toBe("99");
expect(seller.City).toBe("78");
expect(seller.Wereda).toBe("1100");
});
it("leaves the static seller values alone when MoR does not list the e-Trade address", async () => {
// e-Trade's free text does not always correspond to a MoR row (here "Yeka" is a MoR *City*
// under ADDIS ABABA, not a locality under BOLE). That must degrade to the static config, which
// MoR has already cleared under rule 7017 — never throw, and never file a guessed code.
const cfg = eimsConfig({
invoice: eimsInvoiceConfig({ sellerRegion: "1", sellerWereda: "13", sellerCity: "101" }),
});
const { service, resolveCompanyData, extractRegistrationData } = build(cfg);
extractRegistrationData.mockReturnValue(registrationData({ woreda: "Yeka" }));
resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} });
await expect(service.refresh()).resolves.toBeUndefined();
const seller = service.getSellerDetails(cfg);
expect(seller.Region).toBe("1");
expect(seller.Wereda).toBe("13");
expect(seller.City).toBe("101");
});
it("VatNumber and Email are always the static value, never touched by e-Trade", async () => {
const cfg = eimsConfig({
invoice: eimsInvoiceConfig({ sellerVatNumber: "0000000000", sellerEmail: "finance@example.et", ...CODES }),
invoice: eimsInvoiceConfig({ sellerVatNumber: "0000000000", sellerEmail: "finance@example.et" }),
});
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} });
@@ -108,7 +122,7 @@ describe("EimsSellerCacheService.getSellerDetails", () => {
describe("EimsSellerCacheService.refresh", () => {
it("keeps the previous snapshot when a refresh fails", async () => {
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) });
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "" }) });
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} });
await service.refresh();
@@ -123,7 +137,7 @@ describe("EimsSellerCacheService.refresh", () => {
it("keeps the previous snapshot on timeout, without waiting for the slow request", async () => {
jest.useFakeTimers();
try {
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) });
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "" }) });
const { service, resolveCompanyData } = build(cfg);
resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} });
await service.refresh();

View File

@@ -3,7 +3,8 @@ import { ConfigService } from "@nestjs/config";
import { EimsConfig } from "../../config/eims.config";
import { ETradeService } from "../companies/services/etrade.service";
import { EimsSellerDetails, resolveOptionalCode } from "../billing/eims-invoice.mapper";
import { tryResolveMorGeo } from "../../config/mor-location.resolver";
import { EimsSellerDetails } from "../billing/eims-invoice.mapper";
import { buildEimsSeller } from "./eims-invoice-context";
const has = (value: string | null | undefined): value is string => Boolean(value && value.trim());
@@ -104,17 +105,24 @@ export class EimsSellerCacheService implements OnModuleInit {
);
if (!businessInfo) return; // no licence on file yet — keep the previous snapshot
const data = this.etrade.extractRegistrationData(businessInfo, companyInfo);
const codes = cfg.invoice;
// e-Trade returns region/zone/woreda as names ("Addis Ababa", "Bole") — resolved through the
// same MoR location master the buyer side uses, since the geography is objective, not
// buyer-specific. `tryResolveMorGeo` never throws: an address MoR does not list simply
// leaves these fields to getSellerDetails' static-config fallback, which is authoritative
// anyway (see the class comment — MoR has already cleared the static seller values under
// rule 7017, so nothing here may override one). e-Trade carries no country field; the
// resolver reads a blank country as domestic, which is correct for EDR's own registration.
const geo = tryResolveMorGeo({
region: data.region,
zone: data.zone,
woreda: data.woreda,
});
this.cached = {
LegalName: data.companyName || undefined,
Phone: data.mobilePhone || data.regularPhone || undefined,
// e-Trade returns region/zone/woreda as names ("Addis Ababa", "Bole") — resolved via the
// same buyer code maps, since the geography is objective, not buyer-specific, despite the
// env var's "BUYER_" prefix. Never throws: an unmapped name just leaves that field to
// getSellerDetails' static-config fallback.
Region: resolveOptionalCode(data.region, codes.buyerRegionCodes),
Wereda: resolveOptionalCode(data.woreda, codes.buyerWeredaCodes),
City: resolveOptionalCode(data.zone, codes.buyerCityCodes),
Region: geo?.Region,
Wereda: geo?.Wereda,
City: geo?.City,
};
} catch (err) {
this.logger.warn(

View File

@@ -32,11 +32,6 @@ export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsIn
paymentMode: "CASH",
paymentTerm: "IMMIDIATE",
unitDefault: "PCS",
buyerCountryCode: null,
buyerCountryCodes: { Ethiopia: "231" }, // test-only, not a confirmed real MoR code
buyerRegionCodes: { "Addis Ababa": "13" },
buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code
buyerCityCodes: { Kirkos: "101" }, // test-only, not a confirmed real MoR code
taxCodeByChargeType: {},
taxRateByChargeType: {},
exciseByChargeType: {},

View File

@@ -1,11 +1,17 @@
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
import { Invoice } from '../../billing/entities/invoice.entity';
import { invoicePaymentMethodExpr } from '../../billing/invoice-settlement.util';
import { PaymentEntity } from '../../payment/entities/payment.entity';
import { Booking } from '../../bookings/entities/booking.entity';
import { Company } from '../../companies/entities/company.entity';
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util';
import { ExportDataset } from '../export.types';
/** Same expression the list endpoint filters by, in this dataset's aliases. */
const PAYMENT_METHOD = invoicePaymentMethodExpr('i', 'p');
/**
* Sensitive EIMS internals are deliberately absent: `eims_signed_qr` (a
* signature blob) and `eims_last_error` (a raw error dump). The
@@ -26,8 +32,16 @@ export const invoicesDataset: ExportDataset = {
// with a second query. In a dataset it is just a join by column.
{ alias: 'slc', entity: ShippingLineCompany, on: 'slc.id = i.shipping_line_company_id' },
{ alias: 'rel', entity: Invoice, on: 'rel.id = i.related_invoice_id' },
// The gateway payment behind the invoice — provider method and its
// transaction reference. Always joined: `scope()` filters on it.
{ alias: 'p', entity: PaymentEntity, on: 'p.id = i.payment_id' },
// Booking behind the invoice, for the PNR alone. `i.source_id` is a bare
// varchar pointer that is not always a UUID (EIMS self-test rows carry a
// slug), so the cast goes on `bk.id`, never on `source_id` — casting the
// other way throws on those rows.
{ alias: 'bk', entity: Booking, on: "bk.id::text = i.source_id AND i.source = 'booking'" },
],
alwaysJoin: ['c'],
alwaysJoin: ['c', 'p', 'bk'],
groups: [
{ id: 'invoice', label: 'Invoice' },
@@ -66,6 +80,12 @@ export const invoicesDataset: ExportDataset = {
{ key: 'currency', label: 'Currency', type: 'string', group: 'amounts', default: true, select: 'i.currency' },
{ key: 'paidAt', label: 'Paid at', type: 'datetime', group: 'payment', select: `to_char(i.paid_at, 'YYYY-MM-DD HH24:MI')` },
{ key: 'paymentMethod', label: 'Payment method', type: 'string', group: 'payment', default: true, requires: ['p'], select: PAYMENT_METHOD, sortExpr: PAYMENT_METHOD },
{ key: 'transactionRef', label: 'Transaction ref', type: 'string', group: 'payment', requires: ['p'], select: 'p.transaction_id' },
// The CBE_BILL reference the customer pays against — stamped onto the
// booking at payment-initiation time, not held on the invoice or payment.
{ key: 'pnrCode', label: 'PNR', type: 'string', group: 'payment', requires: ['bk'], select: 'bk.pnr_code' },
{ key: 'paymentStatus', label: 'Payment status', type: 'string', group: 'payment', requires: ['p'], select: 'p.status::text' },
{
key: 'daysOverdue', label: 'Days overdue', type: 'number', group: 'payment',
select: `CASE WHEN i.balance_amount > 0 AND i.due_at < now()
@@ -104,6 +124,7 @@ export const invoicesDataset: ExportDataset = {
{ key: 'status', label: 'Status (single)', type: 'text' },
{ key: 'sources', label: 'Source', type: 'multiselect' },
{ key: 'eimsStatuses', label: 'EIMS status', type: 'multiselect' },
{ key: 'paymentMethods', label: 'Payment method', type: 'multiselect' },
{ key: 'currency', label: 'Currency', type: 'select', options: [
{ value: 'ETB', label: 'ETB' },
{ value: 'USD', label: 'USD' },
@@ -113,7 +134,7 @@ export const invoicesDataset: ExportDataset = {
{ key: 'hasBalance', label: 'Outstanding only', type: 'text' },
{ key: 'overdue', label: 'Overdue only', type: 'text' },
{ key: 'companyId', label: 'Customer', type: 'text' },
{ key: 'search', label: 'Search invoice no. or customer', type: 'text' },
{ key: 'search', label: 'Search invoice no., customer, PNR or transaction ref', type: 'text' },
],
defaultSort: { key: 'issuedAt', dir: 'DESC' },
@@ -132,6 +153,10 @@ export const invoicesDataset: ExportDataset = {
if (sources?.length) qb.andWhere('i.source IN (:...sources)', { sources });
const eimsStatuses = params.eimsStatuses as string[] | null;
if (eimsStatuses?.length) qb.andWhere('i.eims_status IN (:...eimsStatuses)', { eimsStatuses });
const paymentMethods = params.paymentMethods as string[] | null;
if (paymentMethods?.length) {
qb.andWhere(`${PAYMENT_METHOD} IN (:...paymentMethods)`, { paymentMethods });
}
// Casing has drifted in the data ("usd" rows exist) — normalise both sides,
// same as the list endpoint does.
if (params.currency) {
@@ -146,7 +171,18 @@ export const invoicesDataset: ExportDataset = {
if (params.overdue === 'true') qb.andWhere('i.balance_amount > 0 AND i.due_at < now()');
if (params.companyId) qb.andWhere('i.company_id = :companyId', { companyId: params.companyId });
if (params.search) {
qb.andWhere('(i.invoice_number ILIKE :search OR c.name ILIKE :search)', { search: `%${params.search as string}%` });
// Same reach as the list page's search box, minus the source-record
// lookups it does with correlated subqueries: number, customer, the PNR
// the customer pays against, and the payment references support desks
// quote back.
qb.andWhere(
`(i.invoice_number ILIKE :search
OR c.name ILIKE :search
OR bk.pnr_code ILIKE :search
OR p.transaction_id ILIKE :search
OR p.merchant_order_id ILIKE :search)`,
{ search: `%${params.search as string}%` },
);
}
// ACL: invoices.source_id is a varchar pointer at the originating booking.
applyBookingRefDirectionScope(qb, 'i.source_id', directions);

View File

@@ -3,7 +3,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { CurrentUser } from '@edr/api-common';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { FreightJwtGuard } from '../../common/freight-jwt.guard';
import type { Response } from 'express';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
@@ -57,7 +57,7 @@ const toCatalogEntry = (dataset: ExportDataset): ExportCatalogEntry => ({
@ApiTags('Exports')
@ApiBearerAuth()
@Controller('exports')
@UseGuards(JwtGuard)
@UseGuards(FreightJwtGuard)
export class ExportsController {
constructor(
private readonly runner: ExportRunnerService,

View File

@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { WarehousesModule } from '../warehouses/warehouses.module';
import { DjiboutiIncident } from './entities/djibouti-incident.entity';
import { EmptyContainerReturn } from './entities/empty-container-return.entity';
@@ -18,9 +20,12 @@ import { ImportOperationsService } from './import-operations.service';
]),
// WarehouseReleaseDocumentService (the shared PDF renderer) for the
// equipment interchange receipt; BookingsModule for the customer
// ownership check on that same route.
// ownership check on that same route; the notification modules to tell
// the customer their receipt is ready at handover.
WarehousesModule,
BookingsModule,
NotificationInboxModule,
NotificationsModule,
],
controllers: [ImportOperationsController],
providers: [ImportOperationsService],

View File

@@ -1,9 +1,13 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { NotificationAudience, NotificationType } from '@edr/types';
import { LogoSettingsService } from '../logo-settings/logo-settings.service';
import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { NotificationsService } from '../notifications/notifications.service';
import { sendCompanyChannels } from '../notifications/notify-company.util';
import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service';
import {
CreateDjiboutiIncidentDto,
@@ -35,6 +39,8 @@ const DAMAGE_INCIDENTS: DjiboutiIncidentType[] = [
@Injectable()
export class ImportOperationsService {
private readonly logger = new Logger(ImportOperationsService.name);
constructor(
@InjectRepository(DjiboutiIncident)
private readonly incidents: Repository<DjiboutiIncident>,
@@ -44,6 +50,8 @@ export class ImportOperationsService {
private readonly emptyReturns: Repository<EmptyContainerReturn>,
private readonly pdfDocuments: WarehouseReleaseDocumentService,
private readonly logoSettings: LogoSettingsService,
private readonly inbox: NotificationInboxService,
private readonly notifications: NotificationsService,
) {}
listIncidents(bookingId?: string) {
@@ -161,7 +169,7 @@ export class ImportOperationsService {
async createEmptyReturn(dto: CreateEmptyContainerReturnDto) {
const returnDate = dto.returnDate ? new Date(dto.returnDate) : new Date();
return this.emptyReturns.save(
const saved = await this.emptyReturns.save(
this.emptyReturns.create({
containerNumber: dto.containerNumber,
bookingId: dto.bookingId ?? null,
@@ -180,6 +188,15 @@ export class ImportOperationsService {
],
}),
);
// RETURNED is the physical interchange itself — the customer's/trucker's
// custody of the box ends here, EDR's begins. The receipt exists from this
// point on, so tell the customer now, not at some later internal status.
// Standalone returns (no booking) have no company to notify.
if (saved.bookingId) {
await this.notifyEquipmentInterchangeReady(saved);
}
return saved;
}
/**
@@ -257,6 +274,34 @@ export class ImportOperationsService {
return this.emptyReturns.findOneOrFail({ where: { id } });
}
private async notifyEquipmentInterchangeReady(row: EmptyContainerReturn): Promise<void> {
try {
const [booking]: Array<{ companyId: string | null; reference: string }> =
await this.emptyReturns.manager.query(
`SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[row.bookingId],
);
if (!booking?.companyId) return;
const body = `Container ${row.containerNumber} was handed over${
row.facility ? ` at ${row.facility}` : ''
}. Your equipment interchange receipt for booking ${booking.reference} is ready to download from the portal.`;
await this.inbox.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.DOCUMENT_ACTION,
title: 'Equipment interchange receipt ready',
body,
link: `/bookings/${row.bookingId}`,
data: { bookingId: row.bookingId, emptyContainerReturnId: row.id },
});
await sendCompanyChannels(this.emptyReturns.manager.connection, this.notifications, booking.companyId, body);
} catch (err) {
this.logger.warn(
`Failed to notify equipment interchange ready for return ${row.id}: ${(err as Error).message}`,
);
}
}
async getEmptyReturnOrThrow(id: string): Promise<EmptyContainerReturn> {
const row = await this.emptyReturns.findOne({ where: { id } });
if (!row) {

View File

@@ -10,7 +10,7 @@ import {
UseGuards,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
import { FreightJwtGuard } from "../../common/freight-jwt.guard";
import {
AuthUserPayload,
@@ -21,7 +21,7 @@ import { NotificationInboxService } from "./notification-inbox.service";
@ApiTags("notifications")
@ApiBearerAuth()
@UseGuards(JwtGuard)
@UseGuards(FreightJwtGuard)
@Controller("notifications")
export class NotificationInboxController {
constructor(private readonly service: NotificationInboxService) {}

View File

@@ -67,6 +67,24 @@ export class UpdateOperationsStandardsDto {
@Min(0)
delayToleranceMinutes?: number;
/**
* Handling standards have no spec figure, so they are the only two that may
* be cleared: null puts the report back to reporting hours without a rate.
*/
@ApiPropertyOptional({ example: 6.75, nullable: true })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0.01)
handlingStandardHoursContainer?: number | null;
@ApiPropertyOptional({ example: 12, nullable: true })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0.01)
handlingStandardHoursBulk?: number | null;
@ApiPropertyOptional({ example: 20 })
@IsOptional()
@Transform(toNumber)

View File

@@ -179,6 +179,34 @@ export class OperationsStandard extends BaseEntity {
@Column({ name: 'default_full_trainset_wagons', type: 'int', default: 50 })
defaultFullTrainsetWagons!: number;
/**
* Standard loading-and-unloading time for a container train's stop, in hours.
*
* Null until a planner sets it, and deliberately so: the reporting spec names
* no handling standard, so an unset value reports no rate rather than judging
* a train against a guess. Same for the bulk figure below.
*/
@Column({
name: 'handling_standard_hours_container',
type: 'numeric',
precision: 6,
scale: 2,
nullable: true,
transformer: asNumber,
})
handlingStandardHoursContainer?: number | null;
/** Standard loading-and-unloading time for a bulk train's stop, in hours. */
@Column({
name: 'handling_standard_hours_bulk',
type: 'numeric',
precision: 6,
scale: 2,
nullable: true,
transformer: asNumber,
})
handlingStandardHoursBulk?: number | null;
/** IAM user id of the last operator to change a standard. */
@Column({ name: 'updated_by_id', type: 'uuid', nullable: true })
updatedById?: string | null;

View File

@@ -1,93 +1,173 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { ReportContext, ReportDefinition } from '../report.types';
import {
ACTUAL_TONS_EXPR,
CARGO_CATEGORY_EXPR,
CARGO_CATEGORY_FILTER,
CARGO_CATEGORY_LABEL_EXPR,
ALLOC_CONTAINERS_20,
ALLOC_CONTAINERS_40,
CHARGED_TONS_EXPR,
LOADED_WAGONS_EXPR,
OPERATIONS_FILTERS,
SCHEDULE_EMPTY_WAGONS,
REVENUE_CARGO_CATEGORY_EXPR,
REVENUE_CARGO_FILTER,
SCHEDULE_KM_EXPR,
TEU_EXPR,
allocationLedgerQb,
applyCategoryFilter,
distanceKmBetween,
} from '../operations-classification';
import { CATEGORY_LABEL_OF } from '../revenue-classification';
/**
* Distance and empty-wagon count belong to the departure, so they are constant
* within a group that includes `ts.id` — MAX() satisfies Postgres without
* dragging a scalar subselect through the GROUP BY.
* A leg is one station-to-station move the train actually made: two consecutive
* checkpoints at different yards. DISTINCT because a train that works the same
* pair twice in one departure is still one leg — without it the join would fan
* the cargo out again and double every SUM in the group.
*/
const ROUTE_KM = `MAX(${SCHEDULE_KM_EXPR})`;
const EMPTY_WAGONS = `MAX(${SCHEDULE_EMPTY_WAGONS})`;
const LEGS = `(
SELECT DISTINCT e.train_schedule_id, e.from_yard_id, e.to_yard_id
FROM (
SELECT ev.train_schedule_id,
ev.yard_id AS from_yard_id,
lead(ev.yard_id) OVER (
PARTITION BY ev.train_schedule_id ORDER BY ev.occurred_at
) AS to_yard_id
FROM freight.train_checkpoint_events ev
WHERE ev.deleted_at IS NULL
) e
WHERE e.to_yard_id IS NOT NULL AND e.to_yard_id <> e.from_yard_id
)`;
/**
* LEFT joined, and both ends fall back to the schedule's own corridor: a
* departure with no checkpoints logged has no legs, and must keep the single
* origin-to-destination row it had before this report knew about legs.
*/
const LEG_FROM = 'COALESCE(leg.from_yard_id, ts.origin_station_id)';
const LEG_TO = 'COALESCE(leg.to_yard_id, ts.destination_station_id)';
/**
* Distance is constant within a group that includes `ts.id` and the leg —
* MAX() satisfies Postgres without dragging a scalar subselect through the
* GROUP BY.
*/
const LEG_KM = `MAX(${distanceKmBetween(LEG_FROM, LEG_TO)})`;
/**
* The ledger carries the empty wagons as rows of their own, so both counts are
* plain aggregates over the group: an empty-wagon row has no loaded wagons and
* a cargo row has no empty ones. Read down a departure's rows and its wagons
* add up once, instead of every row repeating the train's empty total.
*/
const EMPTY_WAGONS = 'COUNT(DISTINCT tsw.id) FILTER (WHERE wba.id IS NULL)';
const TOTAL_WAGONS = 'COUNT(DISTINCT tsw.id)::int';
/**
* Cargo in the revenue vocabulary, plus the wagon that carried none.
*
* The label is built out of the key expression rather than beside it: Postgres
* only accepts an aggregate-query column inside a GROUP BY expression it can
* match verbatim, so a second `wba.id IS NULL` test of its own would demand
* `wba.id` in the GROUP BY — which would split the grain down to one row per
* allocation.
*/
const CATEGORY_EXPR = `CASE WHEN wba.id IS NULL THEN 'EMPTY_WAGON'
ELSE ${REVENUE_CARGO_CATEGORY_EXPR} END`;
const CATEGORY_LABEL_EXPR = `CASE WHEN (${CATEGORY_EXPR}) = 'EMPTY_WAGON' THEN 'Empty wagon'
ELSE ${CATEGORY_LABEL_OF(CATEGORY_EXPR)} END`;
/**
* Ton/Km and Vehicle-Km are NULL — not zero — when the yard pair has no
* configured distance. A missing distance is not a zero distance, and zeroing
* it would understate the corridor's work without anyone noticing.
*/
const TON_KM = `ROUND((${CHARGED_TONS_EXPR})::numeric * ${ROUTE_KM}, 1)::float8`;
const VEHICLE_KM = `ROUND(${EMPTY_WAGONS}::numeric * ${ROUTE_KM}, 1)::float8`;
const TON_KM = `ROUND((${CHARGED_TONS_EXPR})::numeric * ${LEG_KM}, 1)::float8`;
const VEHICLE_KM = `ROUND(${EMPTY_WAGONS}::numeric * ${LEG_KM}, 1)::float8`;
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const qb = allocationLedgerQb(ctx);
applyCategoryFilter(qb, ctx.params);
const qb = allocationLedgerQb(ctx, { includeEmptyWagons: true });
applyCategoryFilter(qb, ctx.params, CATEGORY_EXPR);
return qb;
}
/** The row grain: one leg of one departure. Only `query()` needs it — the KPIs
* are corridor-level and would count the same cargo once per leg if they had
* this join. */
function legQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
return baseQuery(ctx)
.leftJoin(LEGS, 'leg', 'leg.train_schedule_id = ts.id')
.leftJoin(Yard, 'lfy', `lfy.id = ${LEG_FROM}`)
.leftJoin(Yard, 'lty', `lty.id = ${LEG_TO}`);
}
export const chargedVsActualVolumeReport: ReportDefinition = {
key: 'charged-vs-actual-volume',
title: 'Charged and Actual Volumes',
description:
'Charged versus actual volume per train and cargo type, with Ton/Km and Vehicle-Km. ' +
'Charged volume is the standard weight capacity — 20 and 40 tons per laden container, ' +
'2.24 and 3.88 empty, 70 tons per wagon of steel or fertilizer, 38 for perishables — ' +
'all editable in Operating standards. Actual volume is what the marshalling recorded. ' +
'Vehicle-Km counts the empty wagons on that train, so it repeats across the trains ' +
'cargo types rather than being split between them.',
'Charged versus actual volume per leg and cargo type, with Ton/Km and Vehicle-Km. ' +
'A leg is one station-to-station move the train actually made, read from its logged ' +
'checkpoints; a departure with no checkpoints logged shows as its single planned ' +
'corridor. Charged volume is the standard weight capacity — 20 and 40 tons per laden ' +
'container, 2.24 and 3.88 empty, 70 tons per wagon of steel or fertilizer, 38 for ' +
'perishables — all editable in Operating standards. Actual volume is what the ' +
'marshalling recorded. Cargo types are the revenue categories the money side bills ' +
'against, so a corridors tonnage and its revenue read in the same buckets; wagons ' +
'that carried nothing are their own “Empty wagon” line. Volumes and wagon counts ' +
'belong to the train, not to the leg, so they repeat on every leg it ran rather than ' +
'being split between them — the KPIs above count each train once. Ton/Km and ' +
'Vehicle-Km are the exception and are the legs own, so they add up across legs into ' +
'the real corridor figure.',
group: 'Operations',
filters: [...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER],
filters: [...OPERATIONS_FILTERS, REVENUE_CARGO_FILTER],
columns: [
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' },
{ key: 'departedAt', label: 'Departure', type: 'date', sortable: true, sortExpr: 'ts.scheduled_departure_date' },
{ key: 'station', label: 'Station', type: 'string' },
{ key: 'category', label: 'Cargo type', type: 'string', sortable: true, sortExpr: CARGO_CATEGORY_EXPR },
{ key: 'legFrom', label: 'From', type: 'string', sortable: true, sortExpr: 'COALESCE(lfy.label, lfy.code)' },
{ key: 'legTo', label: 'To', type: 'string', sortable: true, sortExpr: 'COALESCE(lty.label, lty.code)' },
{ key: 'category', label: 'Cargo type', type: 'string', sortable: true, sortExpr: CATEGORY_EXPR },
{ key: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true },
{ key: 'actualTons', label: 'Actual volume', type: 'tons', sortable: true },
{ key: 'containers20', label: '20ft', type: 'number', sortable: true },
{ key: 'containers40', label: '40ft', type: 'number', sortable: true },
{ key: 'teu', label: 'TEU', type: 'number' },
{ key: 'wagons', label: 'Loaded wagons', type: 'number' },
{ key: 'emptyWagons', label: 'Empty wagons', type: 'number' },
{ key: 'totalWagons', label: 'Total wagons', type: 'number', sortable: true },
{ key: 'distanceKm', label: 'Distance (km)', type: 'number' },
{ key: 'tonKm', label: 'Ton/Km', type: 'number', sortable: true },
{ key: 'vehicleKm', label: 'Vehicle-Km', type: 'number' },
],
defaultSort: { key: 'departedAt', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
return legQuery(ctx)
.select("COALESCE(ts.train_number, '—')", 'trainNumber')
.addSelect(`to_char(COALESCE(ts.actual_departure_at, ts.scheduled_departure_date), 'YYYY-MM-DD')`, 'departedAt')
.addSelect("COALESCE(oy.label, oy.code, '?') || ' → ' || COALESCE(dy.label, dy.code, '?')", 'station')
.addSelect(CARGO_CATEGORY_LABEL_EXPR, 'category')
.addSelect(`to_char(COALESCE(ts.actual_departure_at, ts.scheduled_departure_date), 'YYYY-MM-DD HH24:MI')`, 'departedAt')
.addSelect("COALESCE(lfy.label, lfy.code, '?')", 'legFrom')
.addSelect("COALESCE(lty.label, lty.code, '?')", 'legTo')
.addSelect(CATEGORY_LABEL_EXPR, 'category')
.addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 2)::float8`, 'chargedTons')
.addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 2)::float8`, 'actualTons')
.addSelect(`COALESCE(SUM(${ALLOC_CONTAINERS_20}), 0)::int`, 'containers20')
.addSelect(`COALESCE(SUM(${ALLOC_CONTAINERS_40}), 0)::int`, 'containers40')
.addSelect(TEU_EXPR, 'teu')
.addSelect(LOADED_WAGONS_EXPR, 'wagons')
.addSelect(`${EMPTY_WAGONS}::int`, 'emptyWagons')
.addSelect(`${ROUTE_KM}::float8`, 'distanceKm')
.addSelect(TOTAL_WAGONS, 'totalWagons')
.addSelect(`${LEG_KM}::float8`, 'distanceKm')
.addSelect(TON_KM, 'tonKm')
.addSelect(VEHICLE_KM, 'vehicleKm')
.groupBy('ts.id')
.addGroupBy('ts.train_number')
.addGroupBy('ts.actual_departure_at')
.addGroupBy('ts.scheduled_departure_date')
.addGroupBy('oy.label')
.addGroupBy('oy.code')
.addGroupBy('dy.label')
.addGroupBy('dy.code')
.addGroupBy(CARGO_CATEGORY_EXPR);
.addGroupBy('leg.from_yard_id')
.addGroupBy('leg.to_yard_id')
.addGroupBy('lfy.label')
.addGroupBy('lfy.code')
.addGroupBy('lty.label')
.addGroupBy('lty.code')
.addGroupBy(CATEGORY_EXPR);
},
async summary(ctx) {
const row = await baseQuery(ctx)

View File

@@ -91,8 +91,8 @@ export const contractUtilizationReport: ReportDefinition = {
.addSelect('c.name', 'customer')
.addSelect('ct.status', 'status')
.addSelect('ct.contract_kind', 'kind')
.addSelect(`to_char(ct.contract_valid_from, 'YYYY-MM-DD')`, 'validFrom')
.addSelect(`to_char(ct.contract_valid_until, 'YYYY-MM-DD')`, 'validUntil')
.addSelect(`to_char(ct.contract_valid_from, 'YYYY-MM-DD HH24:MI')`, 'validFrom')
.addSelect(`to_char(ct.contract_valid_until, 'YYYY-MM-DD HH24:MI')`, 'validUntil')
.addSelect('COALESCE(cap.committed, 0)::float8', 'committed')
.addSelect('COALESCE(booked.tons, 0)::float8', 'bookedTons')
.addSelect('COALESCE(booked.cnt, 0)', 'bookings')

View File

@@ -3,6 +3,7 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { Company } from '../../companies/entities/company.entity';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
import { ReportContext, ReportDefinition } from '../report.types';
// FirstMile and LastMile are separate tables with an identical shape (status,
@@ -27,11 +28,11 @@ const STATUS_OPTIONS = [
];
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(LEG_UNION, 'fl')
.innerJoin(Booking, 'b', 'b.id = fl.booking_id')
.innerJoin(Booking, 'b', 'b.id = fl.booking_id AND b.deleted_at IS NULL')
.leftJoin(Company, 'c', 'c.id = b.company_id')
.leftJoin(Vehicle, 'v', 'v.id = fl.vehicle_id')
.where('1 = 1');
@@ -41,6 +42,10 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
if (params.dateTo) qb.andWhere('fl.created_at < :dateTo', { dateTo: params.dateTo });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('fl.status IN (:...statuses)', { statuses });
// Every leg hangs off a booking, so the trade scope is the booking's own
// direction — the same rule the booking-grain reports apply.
applyDirectionScope(qb, 'b.trade_direction', directions);
return qb;
}
@@ -72,7 +77,7 @@ export const firstLastMileBookingsReport: ReportDefinition = {
.addSelect('fl.status', 'status')
.addSelect("COALESCE(v.plate_number, '—')", 'truck')
.addSelect("CASE WHEN fl.vehicle_id IS NOT NULL THEN 'Assigned' ELSE 'Unassigned' END", 'assigned')
.addSelect(`to_char(fl.created_at, 'YYYY-MM-DD')`, 'createdAt');
.addSelect(`to_char(fl.created_at, 'YYYY-MM-DD HH24:MI')`, 'createdAt');
},
async summary(ctx) {
const row = await baseQuery(ctx)

View File

@@ -2,22 +2,28 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { ScheduleWagonAdjustmentLog } from '../../train-schedules/entities/schedule-wagon-adjustment-log.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { directionScopeSql } from '../../user-trade-access/trade-scope.util';
import { ReportContext, ReportDefinition } from '../report.types';
// ADD = allocated, REMOVE = cancelled. SWITCH (a physical wagon swap, net
// count unchanged) is excluded — it's neither an allocation nor a cancellation.
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(ScheduleWagonAdjustmentLog, 'l')
.leftJoin(TrainSchedule, 'ts', 'ts.id = l.train_schedule_id')
.leftJoin(TrainSchedule, 'ts', 'ts.id = l.train_schedule_id AND ts.deleted_at IS NULL')
.where('l.deleted_at IS NULL')
.andWhere("l.action IN ('ADD', 'REMOVE')");
if (params.dateFrom) qb.andWhere('l.occurred_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('l.occurred_at < :dateTo', { dateTo: params.dateTo });
if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction });
// A log row whose schedule is gone carries no direction to scope by and
// stays visible — the rule the other ledgers apply to booking-less rows.
const scope = directionScopeSql('ts.direction', directions);
qb.andWhere(`(ts.id IS NULL OR ${scope.sql})`, scope.params);
return qb;
}

View File

@@ -3,13 +3,14 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
import { ReportContext, ReportDefinition } from '../report.types';
// train_set_wagons.assigned_weight_tons is the planned load per slot, already
// maintained by the wagon-allocation flow — no need to re-derive it from
// bulk/container line items.
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(TrainSetWagon, 'tsw')
@@ -24,6 +25,8 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
qb.andWhere('ts.scheduled_departure_date >= :dateFrom', { dateFrom: params.dateFrom });
}
if (params.dateTo) qb.andWhere('ts.scheduled_departure_date < :dateTo', { dateTo: params.dateTo });
applyDirectionScope(qb, 'ts.direction', directions);
return qb;
}
@@ -49,7 +52,7 @@ export const loadedCapacityReport: ReportDefinition = {
query(ctx) {
return baseQuery(ctx)
.select('ts.train_number', 'trainNumber')
.addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD')`, 'departureDate')
.addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD HH24:MI')`, 'departureDate')
.addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType')
.addSelect('COUNT(*)::int', 'wagons')
.addSelect('COALESCE(SUM(tsw.capacity_tons), 0)::float8', 'capacityTons')

View File

@@ -0,0 +1,30 @@
import { visibleColumns } from '../report-runner.service';
import { loadingUnloadingReport as def } from './loading-unloading.report';
/**
* The two grains select different columns — per train the stop's own times,
* per station the averages over it. A column shown under a grain its query
* doesn't select is a blank column; a column SORTED under one is a 42703.
*/
describe('loading-unloading', () => {
const keys = (grain: string) => visibleColumns(def, { grain }).map((c) => c.key);
it('shows the stop times per train and the averages per station, never both', () => {
expect(keys('train')).toEqual(expect.arrayContaining(['arrivedAt', 'loadUnloadHours']));
expect(keys('train')).not.toEqual(expect.arrayContaining(['avgLoadUnloadHours', 'stops']));
expect(keys('station')).toEqual(expect.arrayContaining(['avgLoadUnloadHours', 'stops']));
expect(keys('station')).not.toEqual(expect.arrayContaining(['arrivedAt', 'trainNumber']));
});
it('sorts by a column both grains select, so the default sort never 42703s', () => {
for (const grain of ['train', 'station']) {
expect(keys(grain)).toContain(def.defaultSort!.key);
}
});
it("defaults the grain, so an unset filter can't show the wrong half", () => {
const grain = def.filters.find((f) => f.key === 'grain')!.defaultValue;
expect(grain).toBe('train');
expect(keys(grain!)).toEqual(keys('train'));
});
});

View File

@@ -0,0 +1,376 @@
import { ReportContext, ReportDefinition, ReportFilterDef } from '../report.types';
import {
COUNTRY_FILTER,
DIRECTION_FILTER,
TRAIN_TYPE_FILTER,
cycleRateExpr,
handlingHours,
hoursBetween,
loadingEnd,
loadingHours,
loadingSource,
loadingStart,
otherActivityHours,
stationStaysQb,
unloadingHours,
} from '../operations-classification';
import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-classification';
/**
* Loading and unloading per train — the spec's own report format: train number,
* total loading and unloading time, other activity, station staying time.
*
* Two shapes, one definition. Per train the row is the stop itself: the logged
* arrival, departure, unloading and loading times and that stop's own
* durations, because the train number is what makes a specific stop worth
* naming. Per station it rolls up into the chosen period — one row per station,
* averaged over every train that called there, which is what "for week report,
* calculate average in the week" asks for. The station stays in both grains
* because a train works both ends of the corridor and the standard it is judged
* against differs by side (10h Ethiopia, 13h Djibouti) — averaging a train's
* Nagad and Gelan stops together would compare that mixture to one standard.
*
* Container work reads as one handling window (unloading start to loading end)
* because that is how the corridor measures a turnaround; bulk stations, which
* load OR unload rather than both, get the two halves in their own columns.
*
* Averages skip the stops with no handling window rather than counting them as
* zero — `AVG` ignores nulls — so `Stops` is the population and `Handling
* measured` says how much of it the handling averages actually rest on.
*/
const STAYING_HOURS = hoursBetween('s.arrived_at', 's.departed_at');
const HANDLING_HOURS = handlingHours('s');
const OTHER_ACTIVITY_HOURS = otherActivityHours(STAYING_HOURS, HANDLING_HOURS);
const avg = (expr: string): string => `ROUND(AVG((${expr})::numeric), 1)::float8`;
/** Reused verbatim in the GROUP BY — the group key is the coalesced value. */
const TRAIN_NUMBER = "COALESCE(s.train_number, '—')";
const HANDLING_STANDARD = 'MAX(s.handling_standard_hours)';
/**
* Which way the rows roll up.
*
* Per train is the spec's container format; per station is its bulk one, which
* asks for the loading and unloading time AT Negad, BCC, DMP, Sebeta, GMP,
* Adama and Modjo rather than per train. Same measurements either way — only
* the group key moves — so one definition serves both.
*/
const GRAIN_FILTER: ReportFilterDef = {
key: 'grain',
label: 'Group by',
type: 'select',
defaultValue: 'train',
options: [
{ value: 'train', label: 'Train' },
{ value: 'station', label: 'Station' },
],
};
/** Per train the rows are stops, so they carry times; per station, averages. */
const TRAIN_ONLY = { grain: 'station' };
const STATION_ONLY = { grain: 'train' };
/** Same display as the staying-time report, so a stop reads alike in both. */
const at = (expr: string): string => `to_char(${expr}, 'YYYY-MM-DD HH24:MI')`;
/** Whitelisted here, so the user's value never reaches SQL. */
const byStation = (ctx: ReportContext): boolean => ctx.params.grain === 'station';
export const loadingUnloadingReport: ReportDefinition = {
key: 'loading-unloading',
title: 'Loading & Unloading',
description:
'Loading and unloading, at the granularity you choose. Grouped by Train the row is one ' +
'stop — its logged arrival, departure, unloading and loading times and that stops own ' +
'durations. Grouped by Station it is one row per station per period, averaged over every ' +
'train that called there, the way the OCC report publishes it. Total loading and unloading is ' +
'the stops handling window, unloading start to loading end, which is the container ' +
'measure; the unloading and loading columns split it for bulk stations that only do ' +
'one of the two (Nagad, BCC and DMP on the Djibouti side; Sebeta, GMP, Adama and Modjo ' +
'on the Ethiopian) — switch Group by to Station for that view. Other activity is the rest ' +
'of the station stay, against the 10h Ethiopia / 13h Djibouti standard from Operating ' +
'standards. The loading window falls back to the first and last booking loaded here when ' +
'nobody recorded it by hand, which “Loading from” reports as Derived; unloading is only ' +
'ever hand-recorded. Averages rest only on the stops that have a handling window at all — ' +
'“Handling measured” counts them. Handling rate needs a handling standard set in Operating ' +
'standards and reads empty until there is one.',
group: 'Operations',
filters: [
PERIOD_FILTER,
GRAIN_FILTER,
{ key: 'date', label: 'Arrival', type: 'daterange' },
TRAIN_TYPE_FILTER,
DIRECTION_FILTER,
{ key: 'trainNumber', label: 'Train No.', type: 'text' },
{ key: 'station', label: 'Station', type: 'text' },
COUNTRY_FILTER,
],
columns: [
{ key: 'period', label: 'Period', type: 'string', sortable: true },
{
key: 'trainNumber',
label: 'Train No.',
type: 'string',
sortable: true,
hideWhen: TRAIN_ONLY,
},
{ key: 'station', label: 'Station', type: 'string', sortable: true },
{ key: 'country', label: 'Country', type: 'string' },
// Per station this would be a MAX over whatever mix of trains called there.
{
key: 'trainType',
label: 'Train type',
type: 'string',
hideWhen: TRAIN_ONLY,
},
{
key: 'stops',
label: 'Stops',
type: 'number',
sortable: true,
hideWhen: STATION_ONLY,
},
{
key: 'handlingMeasured',
label: 'Handling measured',
type: 'number',
hideWhen: STATION_ONLY,
},
{ key: 'loadingSource', label: 'Loading from', type: 'string' },
// Per train: this stop's own clock, not a mean of several.
{
key: 'arrivedAt',
label: 'Arrived',
type: 'date',
sortable: true,
hideWhen: TRAIN_ONLY,
},
{
key: 'departedAt',
label: 'Departed',
type: 'date',
hideWhen: TRAIN_ONLY,
},
{
key: 'unloadingStartedAt',
label: 'Unloading start',
type: 'date',
hideWhen: TRAIN_ONLY,
},
{
key: 'unloadingCompletedAt',
label: 'Unloading end',
type: 'date',
hideWhen: TRAIN_ONLY,
},
{
key: 'loadingStartedAt',
label: 'Loading start',
type: 'date',
hideWhen: TRAIN_ONLY,
},
{
key: 'loadingCompletedAt',
label: 'Loading end',
type: 'date',
hideWhen: TRAIN_ONLY,
},
{
key: 'unloadingHours',
label: 'Unloading (hrs)',
type: 'number',
sortable: true,
hideWhen: TRAIN_ONLY,
},
{
key: 'loadingHours',
label: 'Loading (hrs)',
type: 'number',
sortable: true,
hideWhen: TRAIN_ONLY,
},
{
key: 'loadUnloadHours',
label: 'Loading + unloading (hrs)',
type: 'number',
sortable: true,
hideWhen: TRAIN_ONLY,
},
{
key: 'otherActivityHours',
label: 'Other activity (hrs)',
type: 'number',
hideWhen: TRAIN_ONLY,
},
{
key: 'stayingHours',
label: 'Staying (hrs)',
type: 'number',
sortable: true,
hideWhen: TRAIN_ONLY,
},
{
key: 'avgUnloadingHours',
label: 'Avg unloading (hrs)',
type: 'number',
sortable: true,
hideWhen: STATION_ONLY,
},
{
key: 'avgLoadingHours',
label: 'Avg loading (hrs)',
type: 'number',
sortable: true,
hideWhen: STATION_ONLY,
},
{
key: 'avgLoadUnloadHours',
label: 'Avg loading + unloading (hrs)',
type: 'number',
sortable: true,
hideWhen: STATION_ONLY,
},
{
key: 'avgOtherActivityHours',
label: 'Avg other activity (hrs)',
type: 'number',
hideWhen: STATION_ONLY,
},
{
key: 'avgStayingHours',
label: 'Avg staying (hrs)',
type: 'number',
sortable: true,
hideWhen: STATION_ONLY,
},
{
key: 'stayStandardHours',
label: 'Staying standard (hrs)',
type: 'number',
},
{ key: 'stayVerdict', label: 'Staying verdict', type: 'string' },
{
key: 'handlingStandardHours',
label: 'Handling standard (hrs)',
type: 'number',
},
{
key: 'handlingRate',
label: 'Handling rate',
type: 'percent',
sortable: true,
},
],
defaultSort: { key: 'period', dir: 'DESC' },
// Only plottable at station grain — per train the rows are individual stops,
// and the frontend drops the chart toggle when its columns are hidden.
chart: { type: 'bar', x: 'station', y: ['avgLoadUnloadHours'] },
query(ctx) {
const { params } = ctx;
// Per train the row IS the stop: its own logged times and its own
// durations, since an average of one stop is just the stop with the clock
// thrown away. Averaging starts where the grain stops naming the train.
if (!byStation(ctx)) {
return stationStaysQb(ctx)
.select(periodExprOn('s.arrived_at', params), 'period')
.addSelect(TRAIN_NUMBER, 'trainNumber')
.addSelect('s.station', 'station')
.addSelect('s.country', 'country')
.addSelect('s.train_type', 'trainType')
.addSelect(loadingSource('s'), 'loadingSource')
.addSelect(at('s.arrived_at'), 'arrivedAt')
.addSelect(at('s.departed_at'), 'departedAt')
.addSelect(at('s.unloading_started_at'), 'unloadingStartedAt')
.addSelect(at('s.unloading_completed_at'), 'unloadingCompletedAt')
.addSelect(at(loadingStart('s')), 'loadingStartedAt')
.addSelect(at(loadingEnd('s')), 'loadingCompletedAt')
.addSelect(unloadingHours('s'), 'unloadingHours')
.addSelect(loadingHours('s'), 'loadingHours')
.addSelect(HANDLING_HOURS, 'loadUnloadHours')
.addSelect(OTHER_ACTIVITY_HOURS, 'otherActivityHours')
.addSelect(STAYING_HOURS, 'stayingHours')
.addSelect('s.standard_hours::float8', 'stayStandardHours')
.addSelect(
`CASE WHEN (${STAYING_HOURS})::numeric <= s.standard_hours
THEN 'Encouraging' ELSE 'Needs reason' END`,
'stayVerdict',
)
.addSelect('s.handling_standard_hours::float8', 'handlingStandardHours')
.addSelect(
cycleRateExpr(`(${HANDLING_HOURS})::numeric`, 's.handling_standard_hours'),
'handlingRate',
);
}
// Reused verbatim in the GROUP BY, per the trap documented on `periodExpr`.
const bucket = periodTruncExprOn('s.arrived_at', params);
return (
stationStaysQb(ctx)
.select(periodExprOn('s.arrived_at', params), 'period')
.addSelect('s.station', 'station')
.addSelect('s.country', 'country')
.addSelect('COUNT(*)::int', 'stops')
.addSelect(`COUNT(${HANDLING_HOURS})::int`, 'handlingMeasured')
// Which side of the COALESCE the loading columns came from. A group that
// mixes both says so rather than claiming either.
.addSelect(
`CASE WHEN COUNT(DISTINCT ${loadingSource('s')}) > 1 THEN 'Mixed'
ELSE MAX(${loadingSource('s')}) END`,
'loadingSource',
)
.addSelect(avg(unloadingHours('s')), 'avgUnloadingHours')
.addSelect(avg(loadingHours('s')), 'avgLoadingHours')
.addSelect(avg(HANDLING_HOURS), 'avgLoadUnloadHours')
.addSelect(avg(OTHER_ACTIVITY_HOURS), 'avgOtherActivityHours')
.addSelect(avg(STAYING_HOURS), 'avgStayingHours')
.addSelect('MAX(s.standard_hours)::float8', 'stayStandardHours')
.addSelect(
`CASE WHEN AVG((${STAYING_HOURS})::numeric) <= MAX(s.standard_hours)
THEN 'Encouraging' ELSE 'Needs reason' END`,
'stayVerdict',
)
.addSelect(`${HANDLING_STANDARD}::float8`, 'handlingStandardHours')
// Same formula the turnaround cycle publishes, so the two read alike.
// NULL standard in, NULL rate out — nothing to measure against yet.
.addSelect(
cycleRateExpr(`AVG((${HANDLING_HOURS})::numeric)`, HANDLING_STANDARD),
'handlingRate',
)
.groupBy(bucket)
.addGroupBy('s.station')
.addGroupBy('s.country')
);
},
async summary(ctx) {
const row = await stationStaysQb(ctx)
.select('COUNT(*)::int', 'stops')
.addSelect(`COUNT(${HANDLING_HOURS})::int`, 'measured')
.addSelect(avg(HANDLING_HOURS), 'avgHandling')
.addSelect(avg(OTHER_ACTIVITY_HOURS), 'avgOther')
.getRawOne<{
stops: number;
measured: number;
avgHandling: number;
avgOther: number;
}>();
return [
{ label: 'Stops measured', value: Number(row?.stops ?? 0) },
{ label: 'Handling measured', value: Number(row?.measured ?? 0) },
{
label: 'Average loading + unloading',
value: Number(row?.avgHandling ?? 0),
unit: 'h',
},
{
label: 'Average other activity',
value: Number(row?.avgOther ?? 0),
unit: 'h',
},
];
},
};

View File

@@ -12,7 +12,10 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
.createQueryBuilder()
.from(Locomotive, 'l')
.leftJoin(Yard, 'y', 'y.id = l.current_yard_id')
.where('l.deleted_at IS NULL');
.where('l.deleted_at IS NULL')
// Names ending '#' are excluded from the fleet report by request; the
// marker is a roster convention, not a column the schema tracks.
.andWhere("COALESCE(l.name, '') NOT LIKE '%#'");
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('l.status IN (:...statuses)', { statuses });

View File

@@ -0,0 +1,45 @@
import {
LINES,
countAliases,
lineRefs,
portWarehouseSummaryReport,
} from "./port-warehouse-summary.report";
/**
* The sheet's lines are SQL fragments over the aggregate's select aliases, so a
* renamed or dropped count is invisible to the type-checker and surfaces as a
* 42703 the first time someone opens the report.
*/
describe("port-warehouse-summary", () => {
it("every line reads a column the aggregate selects", () => {
expect(lineRefs().filter((ref) => !countAliases().includes(ref))).toEqual(
[],
);
});
it("every selected count is used by a line", () => {
expect(
countAliases().filter((alias) => !lineRefs().includes(alias)),
).toEqual([]);
});
it("labels are unique — the sheet groups on them", () => {
const labels = LINES.map((l) => l.label);
expect(new Set(labels).size).toBe(labels.length);
});
it("declares a column for every direction the pivot emits", () => {
const keys = portWarehouseSummaryReport.columns.map((c) => c.key);
expect(keys).toEqual(
expect.arrayContaining([
"sn",
"section",
"metric",
"export",
"import",
"domestic",
"total",
]),
);
});
});

View File

@@ -0,0 +1,322 @@
import { ObjectLiteral, SelectQueryBuilder } from "typeorm";
import { ReportContext, ReportDefinition } from "../report.types";
import {
ALLOC_CONTAINERS_20,
ALLOC_CONTAINERS_40,
OPERATIONS_FILTERS,
SCHEDULE_IS_CONTAINER,
allocationLedgerQb,
} from "../operations-classification";
import { yardOptions } from "../revenue-classification";
/**
* The monthly operations summary a port warehouse publishes — the shape of the
* GMP workbook: one line per operation, counted separately for export and
* import, and again over everything.
*
* Every other operations report is a normal grouped table; this one is a
* transposed count sheet, because that is the artefact being reproduced. It is
* built by aggregating the allocation ledger once per direction and once
* overall, unpivoting each of those rows into one row per named operation, then
* pivoting direction back out into columns.
*
* Two deliberate departures from the workbook:
*
* - **Wagons are counted, not derived.** The workbook computes wagons as
* `20ft/2 + 40ft + bulk wagons` because it has no marshalling record. We do —
* `COUNT(DISTINCT train_set_wagons.id)` is what actually carried the cargo.
* The two disagree whenever a wagon ran part-loaded, and the counted figure
* is the true one.
* - **Demurrage is not here.** It is billed on invoice lines, a different fact
* table entirely; Revenue by Category filtered to Demurrage already answers
* it and joining it in at allocation grain would double-count.
*/
/** Booking-level empty marker, NULL-safe so an allocation with no booking is "laden". */
const IS_EMPTY = "COALESCE(b.equipment_return = 'RETURN', false)";
const IS_CONTAINER_LOAD = "wba.load_type = 'CONTAINER'";
/** The bulk twin of {@link SCHEDULE_IS_CONTAINER} — same train-set grain. */
const SCHEDULE_IS_BULK = `EXISTS (
SELECT 1 FROM freight.wagon_booking_allocations a
JOIN freight.train_set_wagons w ON w.id = a.train_set_wagon_id AND w.deleted_at IS NULL
WHERE w.train_set_id = ts.train_set_id
AND a.deleted_at IS NULL AND a.load_type <> 'CONTAINER'
)`;
const DIRECTION = "COALESCE(b.trade_direction, ts.direction)";
const boxes = (perAllocation: string, cond: string): string =>
`(COALESCE(SUM(${perAllocation}) FILTER (WHERE ${cond}), 0))::int`;
const trains = (cond?: string): string =>
`(COUNT(DISTINCT ts.id)${cond ? ` FILTER (WHERE ${cond})` : ""})::int`;
const wagons = (cond?: string): string =>
`(COUNT(DISTINCT tsw.id)${cond ? ` FILTER (WHERE ${cond})` : ""})::int`;
/**
* The raw counts the sheet is built from, keyed by the alias each is selected
* as. {@link LINES} may only reference these; the spec beside this file is what
* keeps the two in step, since a stale `p.<alias>` is a runtime 42703 that
* neither tsc nor a type-check can see.
*/
const COUNTS: Record<string, string> = {
trains: trains(),
container_trains: trains(
`${SCHEDULE_IS_CONTAINER} AND NOT ${SCHEDULE_IS_BULK}`,
),
bulk_trains: trains(`${SCHEDULE_IS_BULK} AND NOT ${SCHEDULE_IS_CONTAINER}`),
mixed_trains: trains(`${SCHEDULE_IS_CONTAINER} AND ${SCHEDULE_IS_BULK}`),
full_20: boxes(
ALLOC_CONTAINERS_20,
`${IS_CONTAINER_LOAD} AND NOT ${IS_EMPTY}`,
),
full_40: boxes(
ALLOC_CONTAINERS_40,
`${IS_CONTAINER_LOAD} AND NOT ${IS_EMPTY}`,
),
empty_20: boxes(ALLOC_CONTAINERS_20, `${IS_CONTAINER_LOAD} AND ${IS_EMPTY}`),
empty_40: boxes(ALLOC_CONTAINERS_40, `${IS_CONTAINER_LOAD} AND ${IS_EMPTY}`),
full_wagons: wagons(`${IS_CONTAINER_LOAD} AND NOT ${IS_EMPTY}`),
empty_wagons: wagons(`${IS_CONTAINER_LOAD} AND ${IS_EMPTY}`),
bulk_wagons: wagons(`NOT ${IS_CONTAINER_LOAD}`),
wagons: wagons(),
};
/**
* The operation lines, in the workbook's order. `value` is an expression over
* the per-direction aggregate `p`, so a derived line (totals, TEU) is plain
* arithmetic rather than a second pass over the ledger.
*/
export const LINES: { section: string; label: string; value: string }[] = [
{ section: "Trains", label: "Total trains", value: "p.trains" },
{ section: "Trains", label: "Container trains", value: "p.container_trains" },
{ section: "Trains", label: "Bulk cargo trains", value: "p.bulk_trains" },
{
section: "Trains",
label: "Mixed bulk and container trains",
value: "p.mixed_trains",
},
{ section: "Containers", label: "Full containers 20ft", value: "p.full_20" },
{ section: "Containers", label: "Full containers 40ft", value: "p.full_40" },
{
section: "Containers",
label: "Empty containers 20ft",
value: "p.empty_20",
},
{
section: "Containers",
label: "Empty containers 40ft",
value: "p.empty_40",
},
{
section: "Containers",
label: "Total 20ft containers",
value: "p.full_20 + p.empty_20",
},
{
section: "Containers",
label: "Total 40ft containers",
value: "p.full_40 + p.empty_40",
},
{
section: "Containers",
label: "Total containers",
value: "p.full_20 + p.empty_20 + p.full_40 + p.empty_40",
},
{
section: "Containers",
label: "Total TEU",
value: "p.full_20 + p.empty_20 + (p.full_40 + p.empty_40) * 2",
},
{
section: "Wagons",
label: "Wagons loaded with full containers",
value: "p.full_wagons",
},
{
section: "Wagons",
label: "Wagons loaded with empty containers",
value: "p.empty_wagons",
},
{
section: "Wagons",
label: "Wagons loaded with bulk cargo",
value: "p.bulk_wagons",
},
{ section: "Wagons", label: "Total wagons", value: "p.wagons" },
];
/** Every `p.<alias>` a line reads, or the aliases the aggregate offers. */
export const lineRefs = (): string[] => [
...new Set(
LINES.flatMap((l) => [...l.value.matchAll(/\bp\.(\w+)/g)].map((m) => m[1])),
),
];
export const countAliases = (): string[] => Object.keys(COUNTS);
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const qb = allocationLedgerQb(ctx);
if (ctx.params.station) {
// Either end of the corridor — a warehouse reports the trains it worked,
// whichever direction they ran.
qb.andWhere("(oy.code = :station OR dy.code = :station)", {
station: ctx.params.station,
});
}
return qb;
}
/**
* The raw counts, one row per direction — or, with `grouped` false, one row for
* everything under the pseudo-direction `ALL`.
*
* The second form is not a convenience: trains and wagons are counted
* distinctly, and a train carrying both an import and an export booking belongs
* to both directions. Adding the direction columns up would count it twice, so
* the Total column reads this row instead of summing the others.
*/
function aggregate(
ctx: ReportContext,
grouped: boolean,
): SelectQueryBuilder<ObjectLiteral> {
const qb = baseQuery(ctx).select(grouped ? DIRECTION : "'ALL'", "dir");
for (const [alias, expr] of Object.entries(COUNTS)) qb.addSelect(expr, alias);
if (grouped) qb.groupBy(DIRECTION);
return qb;
}
/**
* The bulk sheet: wagons per cargo type. Grouped on the cargo type itself, not
* the coarse cargo category the other operations reports use — the workbook
* lists wheat, sugar and lentils separately and the category vocabulary folds
* all three into BULK.
*/
function bulkByCargoType(
ctx: ReportContext,
grouped: boolean,
): SelectQueryBuilder<ObjectLiteral> {
const qb = baseQuery(ctx)
.andWhere(`NOT ${IS_CONTAINER_LOAD}`)
.select(grouped ? DIRECTION : "'ALL'", "dir")
.addSelect("900", "sn")
.addSelect("'Bulk cargo'", "section")
.addSelect("COALESCE(ct.cargo_type_name, 'Unclassified')", "metric")
.addSelect(wagons(), "value")
.groupBy("ct.cargo_type_name");
if (grouped) qb.addGroupBy(DIRECTION);
return qb;
}
export const portWarehouseSummaryReport: ReportDefinition = {
key: "port-warehouse-summary",
title: "Port Warehouse Operations Summary",
description:
"The monthly count sheet a port warehouse publishes: trains, containers by size and " +
"laden state, wagons and TEU, each split into export and import beside an overall total, " +
"followed " +
"by bulk cargo wagons per cargo type. Pick a station to report one warehouse and a date " +
"range to report one month. Counted from the marshalling record — what was actually put " +
"on the train. Wagons are counted distinctly rather than derived from container counts, " +
"so a part-loaded wagon counts once. Total is counted over everything rather than summed " +
"across the direction columns, because a train carrying both an import and an export " +
"booking belongs to both and would otherwise count twice. Demurrage is billed on invoice lines and is not " +
"part of this report; use Revenue by Category filtered to Demurrage.",
group: "Operations",
filters: [
...OPERATIONS_FILTERS,
{
key: "station",
label: "Station / warehouse",
type: "select",
optionsQuery: yardOptions,
},
],
columns: [
{ key: "sn", label: "S/N", type: "number", sortable: true },
{ key: "section", label: "Section", type: "string", sortable: true },
{
key: "metric",
label: "Name of operation",
type: "string",
sortable: true,
},
{ key: "export", label: "Export", type: "number", sortable: true },
{ key: "import", label: "Import", type: "number", sortable: true },
{ key: "domestic", label: "Domestic", type: "number", sortable: true },
{ key: "total", label: "Total", type: "number", sortable: true },
],
defaultSort: { key: "sn", dir: "ASC" },
query(ctx) {
const aggs = [aggregate(ctx, true), aggregate(ctx, false)];
const bulks = [bulkByCargoType(ctx, true), bulkByCargoType(ctx, false)];
// The fixed lines, unpivoted. sn is the line's position in LINES, so the
// sheet keeps the workbook's order regardless of what the values are.
const values = LINES.map(
(l, i) =>
`(${i + 1}, '${l.section}', '${l.label.replace(/'/g, "''")}', (${l.value})::int)`,
).join(",\n ");
const long = [
...aggs.map(
(agg) => `
SELECT p.dir, v.sn, v.section, v.metric, v.value
FROM (${agg.getQuery()}) p
CROSS JOIN LATERAL (VALUES
${values}
) AS v(sn, section, metric, value)`,
),
...bulks.map(
(bulk) =>
`SELECT bq.dir, bq.sn, bq.section, bq.metric, bq.value FROM (${bulk.getQuery()}) bq`,
),
].join("\n UNION ALL\n");
const dirSum = (dir: string): string =>
`(COALESCE(SUM(l.value) FILTER (WHERE l.dir = '${dir}'), 0))::int`;
return (
ctx.ds
.createQueryBuilder()
.from(`(${long})`, "l")
.setParameters(
Object.assign(
{},
...[...aggs, ...bulks].map((qb) => qb.getParameters()),
),
)
// Renumbered after grouping so the bulk lines continue the sheet's
// numbering instead of all sharing the 900 that ordered them.
.select("(ROW_NUMBER() OVER (ORDER BY l.sn, l.metric))::int", "sn")
.addSelect("l.section", "section")
.addSelect("l.metric", "metric")
.addSelect(dirSum("EXPORT"), "export")
.addSelect(dirSum("IMPORT"), "import")
.addSelect(dirSum("DOMESTIC"), "domestic")
.addSelect(dirSum("ALL"), "total")
.groupBy("l.sn")
.addGroupBy("l.section")
.addGroupBy("l.metric")
);
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select(trains(), "trains")
.addSelect(wagons(), "wagons")
.addSelect(
`${boxes(ALLOC_CONTAINERS_20, IS_CONTAINER_LOAD)} + ${boxes(ALLOC_CONTAINERS_40, IS_CONTAINER_LOAD)} * 2`,
"teu",
)
.getRawOne<{ trains: number; wagons: number; teu: number }>();
return [
{ label: "Trains", value: Number(row?.trains ?? 0) },
{ label: "Wagons", value: Number(row?.wagons ?? 0) },
{ label: "TEU", value: Number(row?.teu ?? 0) },
];
},
};

View File

@@ -378,7 +378,7 @@ export const receivablesPayablesReport: ReportDefinition = {
query(ctx) {
return baseQuery(ctx)
.select(SIDE_LABEL_OF('r.side_key'), 'side')
.addSelect("to_char(r.txn_date, 'YYYY-MM-DD')", 'issuedAt')
.addSelect("to_char(r.txn_date, 'YYYY-MM-DD HH24:MI')", 'issuedAt')
.addSelect('r.doc_ref', 'invoiceNumber')
.addSelect('r.booking_ref', 'bookingRef')
.addSelect('r.booking_status', 'bookingStatus')

View File

@@ -63,7 +63,7 @@ export const revenueReconciliationReport: ReportDefinition = {
defaultSort: { key: 'variance', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD')`, 'issuedAt')
.select(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD HH24:MI')`, 'issuedAt')
.addSelect('i.invoice_number', 'invoiceNumber')
.addSelect("COALESCE(b.reference, '—')", 'bookingRef')
.addSelect(PAYER_EXPR, 'customer')

View File

@@ -1,6 +1,6 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { ObjectLiteral, SelectQueryBuilder } from "typeorm";
import { ReportContext, ReportDefinition } from '../report.types';
import { ReportContext, ReportDefinition } from "../report.types";
import {
PAYMENT_CLASS_EXPR,
PAYER_EXPR,
@@ -13,7 +13,7 @@ import {
currencyOf,
periodExpr,
revenueLedgerQb,
} from '../revenue-classification';
} from "../revenue-classification";
/**
* The gateway payment behind an invoice, for traceability. `invoices.payment_id`
@@ -51,81 +51,109 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
}
export const revenueTransactionsReport: ReportDefinition = {
key: 'revenue-transactions',
title: 'Revenue Transactions',
key: "revenue-transactions",
title: "Revenue Transactions",
description:
'Every billed revenue line, at transaction level — booking reference, invoice number, ' +
'charge type, cargo, quantity and the payment reference behind it. This is the ' +
'drill-down target for the revenue summaries and the audit trail for an export.',
group: 'Finance',
"Every billed revenue line, at transaction level — booking reference, invoice number, " +
"charge type, cargo, quantity and the payment reference behind it. This is the " +
"drill-down target for the revenue summaries and the audit trail for an export.",
group: "Finance",
filters: [
PERIOD_FILTER,
...REVENUE_FILTERS,
{ key: 'period_value', label: 'Period bucket', type: 'text' },
{ key: "period_value", label: "Period bucket", type: "text" },
{
key: 'categoryKey',
label: 'Category (exact)',
type: 'select',
key: "categoryKey",
label: "Category (exact)",
type: "select",
options: REVENUE_CATEGORIES,
},
],
columns: [
{ key: 'issuedAt', label: 'Issued', type: 'date', sortable: true, sortExpr: REVENUE_DATE },
{ key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' },
{ key: 'bookingRef', label: 'Booking', type: 'string', sortable: true, sortExpr: 'b.reference' },
{ key: 'bookingId', label: 'Booking ID', type: 'string' },
{ key: 'payer', label: 'Customer', type: 'string', sortable: true, sortExpr: PAYER_EXPR },
{ key: 'category', label: 'Revenue category', type: 'string', sortable: true, sortExpr: REVENUE_CATEGORY_EXPR },
{ key: 'paymentClass', label: 'Payment class', type: 'string' },
{ key: 'chargeType', label: 'Charge type', type: 'string', sortable: true, sortExpr: 'il.charge_type' },
{ key: 'cargo', label: 'Cargo', type: 'string' },
{ key: 'route', label: 'Route', type: 'string' },
{ key: 'quantity', label: 'Qty', type: 'number' },
{ key: 'unit', label: 'Unit', type: 'string' },
{ key: 'unitRate', label: 'Unit rate', type: 'money' },
{ key: 'amount', label: 'Amount', type: 'money', sortable: true, sortExpr: 'il.amount' },
{ key: 'currency', label: 'Currency', type: 'string' },
{ key: 'invoiceStatus', label: 'Invoice status', type: 'string', sortable: true, sortExpr: 'i.status' },
{ key: 'paymentRef', label: 'Payment ref', type: 'string' },
{ key: 'paymentMethod', label: 'Method', type: 'string' },
{ key: 'paymentStatus', label: 'Payment status', type: 'string' },
{ key: "issuedAt", label: "Issued", type: "date", sortable: true, sortExpr: REVENUE_DATE },
{
key: "invoiceNumber",
label: "Invoice No.",
type: "string",
sortable: true,
sortExpr: "i.invoice_number",
},
{
key: "bookingRef",
label: "Booking",
type: "string",
sortable: true,
sortExpr: "b.reference",
},
{ key: "payer", label: "Customer", type: "string", sortable: true, sortExpr: PAYER_EXPR },
{
key: "category",
label: "Revenue category",
type: "string",
sortable: true,
sortExpr: REVENUE_CATEGORY_EXPR,
},
{ key: "paymentClass", label: "Payment class", type: "string" },
{
key: "chargeType",
label: "Charge type",
type: "string",
sortable: true,
sortExpr: "il.charge_type",
},
{ key: "cargo", label: "Cargo", type: "string" },
{ key: "route", label: "Route", type: "string" },
{ key: "quantity", label: "Qty", type: "number" },
{ key: "unit", label: "Unit", type: "string" },
{ key: "unitRate", label: "Unit rate", type: "money" },
{ key: "amount", label: "Amount", type: "money", sortable: true, sortExpr: "il.amount" },
{ key: "currency", label: "Currency", type: "string" },
{
key: "invoiceStatus",
label: "Invoice status",
type: "string",
sortable: true,
sortExpr: "i.status",
},
{ key: "paymentRef", label: "Payment ref", type: "string" },
{ key: "paymentMethod", label: "Method", type: "string" },
{ key: "paymentStatus", label: "Payment status", type: "string" },
],
defaultSort: { key: 'issuedAt', dir: 'DESC' },
defaultSort: { key: "issuedAt", dir: "DESC" },
query(ctx) {
return baseQuery(ctx)
.select(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD')`, 'issuedAt')
.addSelect('i.invoice_number', 'invoiceNumber')
.addSelect("COALESCE(b.reference, '—')", 'bookingRef')
.addSelect("COALESCE(b.id::text, '')", 'bookingId')
.addSelect(PAYER_EXPR, 'payer')
.addSelect(REVENUE_CATEGORY_EXPR, 'category')
.addSelect(PAYMENT_CLASS_EXPR, 'paymentClass')
.addSelect('il.charge_type', 'chargeType')
.addSelect("COALESCE(ct.cargo_type_name, b.cargo_free_text, '')", 'cargo')
.addSelect("COALESCE(oy.label, '?') || ' → ' || COALESCE(dy.label, '?')", 'route')
.addSelect('il.quantity::float8', 'quantity')
.addSelect("COALESCE(il.metadata->>'unit', '')", 'unit')
.addSelect('ROUND(il.unit_rate, 2)::float8', 'unitRate')
.addSelect('ROUND(il.amount, 2)::float8', 'amount')
.addSelect('il.currency', 'currency')
.addSelect('i.status', 'invoiceStatus')
.select(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD HH24:MI')`, "issuedAt")
.addSelect("i.invoice_number", "invoiceNumber")
.addSelect("COALESCE(b.reference, '—')", "bookingRef")
.addSelect(PAYER_EXPR, "payer")
.addSelect(REVENUE_CATEGORY_EXPR, "category")
.addSelect(PAYMENT_CLASS_EXPR, "paymentClass")
.addSelect("il.charge_type", "chargeType")
.addSelect("COALESCE(ct.cargo_type_name, b.cargo_free_text, '—')", "cargo")
.addSelect("COALESCE(oy.label, '?') || ' → ' || COALESCE(dy.label, '?')", "route")
.addSelect("il.quantity::float8", "quantity")
.addSelect("COALESCE(il.metadata->>'unit', '')", "unit")
.addSelect("ROUND(il.unit_rate, 2)::float8", "unitRate")
.addSelect("ROUND(il.amount, 2)::float8", "amount")
.addSelect("il.currency", "currency")
.addSelect("i.status", "invoiceStatus")
.addSelect(
`COALESCE(${latestPayment('transaction_id')}, ${latestPayment('merchant_order_id')}, '')`,
'paymentRef',
`COALESCE(${latestPayment("transaction_id")}, ${latestPayment("merchant_order_id")}, '')`,
"paymentRef",
)
.addSelect(`COALESCE(${latestPayment('method')}, '')`, 'paymentMethod')
.addSelect(`COALESCE(${latestPayment('status')}, '')`, 'paymentStatus');
.addSelect(`COALESCE(${latestPayment("method")}, '')`, "paymentMethod")
.addSelect(`COALESCE(${latestPayment("status")}, '')`, "paymentStatus");
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select(REVENUE_SUM, 'revenue')
.addSelect('COUNT(*)::int', 'lines')
.addSelect('COUNT(DISTINCT i.id)::int', 'invoices')
.select(REVENUE_SUM, "revenue")
.addSelect("COUNT(*)::int", "lines")
.addSelect("COUNT(DISTINCT i.id)::int", "invoices")
.getRawOne<{ revenue: number; lines: number; invoices: number }>();
return [
{ label: 'Lines', value: Number(row?.lines ?? 0) },
{ label: 'Invoices', value: Number(row?.invoices ?? 0) },
{ label: 'Revenue', value: Number(row?.revenue ?? 0), unit: currencyOf(ctx.params) },
{ label: "Lines", value: Number(row?.lines ?? 0) },
{ label: "Invoices", value: Number(row?.invoices ?? 0) },
{ label: "Revenue", value: Number(row?.revenue ?? 0), unit: currencyOf(ctx.params) },
];
},
};

View File

@@ -1,87 +1,18 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { OperationsStandard } from '../../operations-reporting/entities/operations-standard.entity';
import { TrainCheckpointEvent } from '../../train-scheduling/entities/train-checkpoint-event.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { TrainSet } from '../../train-sets/entities/train-set.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
import { ReportContext, ReportDefinition } from '../report.types';
import { ReportDefinition } from '../report.types';
import {
COUNTRY_FILTER,
DIRECTION_FILTER,
OPS_DATE,
STANDARDS_JOIN,
STATION_STANDARD_HOURS_EXPR,
handlingHours,
hoursBetween,
otherActivityHours,
stationStaysQb,
} from '../operations-classification';
/**
* A stay is an ARRIVED followed by the next DEPARTED at the same station by the
* same physical train — NOT by the same schedule.
*
* When a train turns around at a station the two halves belong to different
* departures: the arrival closes the inbound schedule and the departure opens
* the outbound one. Pairing within a schedule finds only pass-through stops and
* silently drops every turnaround, which is the longest stay a train makes.
*/
const TRAIN_KEY = 'COALESCE(tset.train_id::text, ts.train_set_id::text)';
const STAY_WINDOW = `PARTITION BY ${TRAIN_KEY}, ev.yard_id ORDER BY ev.occurred_at`;
const STAYING_HOURS = hoursBetween('s.arrived_at', 's.departed_at');
const HANDLING_HOURS = handlingHours('s');
const OTHER_ACTIVITY_HOURS = otherActivityHours(STAYING_HOURS, HANDLING_HOURS);
const STANDARD_HOURS = 's.standard_hours';
/** Every logged stop, with the event that followed it at the same station. */
function stopsQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(TrainCheckpointEvent, 'ev')
.innerJoin(TrainSchedule, 'ts', 'ts.id = ev.train_schedule_id AND ts.deleted_at IS NULL')
.leftJoin(TrainSet, 'tset', 'tset.id = ts.train_set_id AND tset.deleted_at IS NULL')
.innerJoin(Yard, 'y', 'y.id = ev.yard_id')
.leftJoin(Yard, 'oy', 'oy.id = ts.origin_station_id')
.leftJoin(Yard, 'dy', 'dy.id = ts.destination_station_id')
.leftJoin(OperationsStandard, 'std', STANDARDS_JOIN)
.where('ev.deleted_at IS NULL')
.andWhere("ev.kind IN ('ARRIVED', 'DEPARTED')")
.select('ts.train_number', 'train_number')
.addSelect("COALESCE(y.label, y.code, '—')", 'station')
.addSelect("COALESCE(y.country, '—')", 'country')
.addSelect('ev.kind', 'kind')
.addSelect('ev.occurred_at', 'arrived_at')
.addSelect(`lead(ev.occurred_at) OVER (${STAY_WINDOW})`, 'departed_at')
.addSelect(`lead(ev.kind) OVER (${STAY_WINDOW})`, 'next_kind')
.addSelect(`ROUND(${STATION_STANDARD_HOURS_EXPR}, 1)`, 'standard_hours')
.addSelect("COALESCE(ev.note, '')", 'note');
if (params.dateFrom) qb.andWhere(`${OPS_DATE} >= :dateFrom`, { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere(`${OPS_DATE} < :dateTo`, { dateTo: params.dateTo });
if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction });
if (params.trainNumber) {
qb.andWhere('ts.train_number ILIKE :trainNumber', {
trainNumber: `%${params.trainNumber as string}%`,
});
}
if (params.station) qb.andWhere('y.code = :station', { station: params.station });
if (params.country) qb.andWhere('y.country = :country', { country: params.country });
applyDirectionScope(qb, 'ts.direction', directions);
return qb;
}
/** Only completed stops — an arrival whose departure was also logged. */
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const inner = stopsQuery(ctx);
return ctx.ds
.createQueryBuilder()
.from(`(${inner.getQuery()})`, 's')
.setParameters(inner.getParameters())
.where("s.kind = 'ARRIVED'")
.andWhere("s.next_kind = 'DEPARTED'");
}
export const stationStayingTimeReport: ReportDefinition = {
key: 'station-staying-time',
title: 'Station Staying Time',
@@ -89,8 +20,9 @@ export const stationStayingTimeReport: ReportDefinition = {
'How long each train stood at each station — the logged arrival to the same trains ' +
'next departure from that station — against the standard for that side of the line ' +
'(10h Ethiopia, 13h Djibouti, both editable in Operating standards). A stop over ' +
'standard needs a reason. Loading and unloading times are not split out: nothing in ' +
'the system records when they start and end yet.',
'standard needs a reason. Loading and unloading time is the stops logged handling ' +
'window, unloading start to loading end, and other activity is whatever is left of the ' +
'stay; both read empty on a stop whose handling was never logged, rather than zero.',
group: 'Operations',
filters: [
{ key: 'date', label: 'Departure', type: 'daterange' },
@@ -106,6 +38,14 @@ export const stationStayingTimeReport: ReportDefinition = {
{ key: 'arrivedAt', label: 'Arrived', type: 'date', sortable: true, sortExpr: 's.arrived_at' },
{ key: 'departedAt', label: 'Departed', type: 'date' },
{ key: 'stayingHours', label: 'Staying (hrs)', type: 'number', sortable: true, sortExpr: STAYING_HOURS },
{
key: 'loadUnloadHours',
label: 'Loading + unloading (hrs)',
type: 'number',
sortable: true,
sortExpr: HANDLING_HOURS,
},
{ key: 'otherActivityHours', label: 'Other activity (hrs)', type: 'number' },
{ key: 'standardHours', label: 'Standard (hrs)', type: 'number' },
{ key: 'varianceHours', label: 'Variance (hrs)', type: 'number' },
{ key: 'verdict', label: 'Verdict', type: 'string' },
@@ -113,13 +53,15 @@ export const stationStayingTimeReport: ReportDefinition = {
],
defaultSort: { key: 'arrivedAt', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
return stationStaysQb(ctx)
.select("COALESCE(s.train_number, '—')", 'trainNumber')
.addSelect('s.station', 'station')
.addSelect('s.country', 'country')
.addSelect(`to_char(s.arrived_at, 'YYYY-MM-DD HH24:MI')`, 'arrivedAt')
.addSelect(`to_char(s.departed_at, 'YYYY-MM-DD HH24:MI')`, 'departedAt')
.addSelect(STAYING_HOURS, 'stayingHours')
.addSelect(HANDLING_HOURS, 'loadUnloadHours')
.addSelect(OTHER_ACTIVITY_HOURS, 'otherActivityHours')
.addSelect(`${STANDARD_HOURS}::float8`, 'standardHours')
.addSelect(`ROUND((${STAYING_HOURS})::numeric - ${STANDARD_HOURS}, 1)::float8`, 'varianceHours')
.addSelect(
@@ -132,18 +74,20 @@ export const stationStayingTimeReport: ReportDefinition = {
.addSelect('s.note', 'reason');
},
async summary(ctx) {
const row = await baseQuery(ctx)
const row = await stationStaysQb(ctx)
.select('COUNT(*)::int', 'stops')
.addSelect(`ROUND(AVG((${STAYING_HOURS})::numeric), 1)::float8`, 'avgHours')
.addSelect(`ROUND(AVG((${HANDLING_HOURS})::numeric), 1)::float8`, 'avgHandling')
.addSelect(
`COUNT(*) FILTER (WHERE (${STAYING_HOURS})::numeric > ${STANDARD_HOURS})::int`,
'overStandard',
)
.getRawOne<{ stops: number; avgHours: number; overStandard: number }>();
.getRawOne<{ stops: number; avgHours: number; avgHandling: number; overStandard: number }>();
return [
{ label: 'Stops measured', value: Number(row?.stops ?? 0) },
{ label: 'Average stay', value: Number(row?.avgHours ?? 0), unit: 'h' },
{ label: 'Average loading + unloading', value: Number(row?.avgHandling ?? 0), unit: 'h' },
{ label: 'Over standard', value: Number(row?.overStandard ?? 0) },
];
},

Some files were not shown because too many files have changed in this diff Show More