diff --git a/4_5767239985799371288.xlsx b/4_5767239985799371288.xlsx new file mode 100644 index 000000000..35cb5744e Binary files /dev/null and b/4_5767239985799371288.xlsx differ diff --git a/EDR-Freight-Priority-Flows-Portal.pdf b/EDR-Freight-Priority-Flows-Portal.pdf new file mode 100644 index 000000000..fe37cc9e8 Binary files /dev/null and b/EDR-Freight-Priority-Flows-Portal.pdf differ diff --git a/EDR-Freight-Priority-Flows.pdf b/EDR-Freight-Priority-Flows.pdf new file mode 100644 index 000000000..621d27995 Binary files /dev/null and b/EDR-Freight-Priority-Flows.pdf differ diff --git a/INV-20260812-00005-QR.png b/INV-20260812-00005-QR.png new file mode 100644 index 000000000..ec1e728d4 Binary files /dev/null and b/INV-20260812-00005-QR.png differ diff --git a/INV-20260812-00005.pdf b/INV-20260812-00005.pdf new file mode 100644 index 000000000..b36573dd2 Binary files /dev/null and b/INV-20260812-00005.pdf differ diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 930c8f1ca..8c40ac952 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -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 +# 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). diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index e10e5dcb7..0c6b1c727 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -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:*", diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 68bbdcba4..ccff74387 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -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() 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); diff --git a/apps/edr-freight-api/src/common/freight-jwt.guard.ts b/apps/edr-freight-api/src/common/freight-jwt.guard.ts new file mode 100644 index 000000000..68b842217 --- /dev/null +++ b/apps/edr-freight-api/src/common/freight-jwt.guard.ts @@ -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 { + 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 { + 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; + } +} diff --git a/apps/edr-freight-api/src/common/freight-permission.util.spec.ts b/apps/edr-freight-api/src/common/freight-permission.util.spec.ts index f3931f78a..562b21156 100644 --- a/apps/edr-freight-api/src/common/freight-permission.util.spec.ts +++ b/apps/edr-freight-api/src/common/freight-permission.util.spec.ts @@ -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 position’s 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); + }); +}); diff --git a/apps/edr-freight-api/src/common/freight-permission.util.ts b/apps/edr-freight-api/src/common/freight-permission.util.ts index bc98a6df4..1078c5ad9 100644 --- a/apps/edr-freight-api/src/common/freight-permission.util.ts +++ b/apps/edr-freight-api/src/common/freight-permission.util.ts @@ -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]; } diff --git a/apps/edr-freight-api/src/common/rule-engine-guards.ts b/apps/edr-freight-api/src/common/rule-engine-guards.ts index ba8096b5b..d7297d3b5 100644 --- a/apps/edr-freight-api/src/common/rule-engine-guards.ts +++ b/apps/edr-freight-api/src/common/rule-engine-guards.ts @@ -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)])), ); diff --git a/apps/edr-freight-api/src/config/eims.config.spec.ts b/apps/edr-freight-api/src/config/eims.config.spec.ts index 127b3ea62..a6aa3895d 100644 --- a/apps/edr-freight-api/src/config/eims.config.spec.ts +++ b/apps/edr-freight-api/src/config/eims.config.spec.ts @@ -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"); - }, - ); - }); -}); diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index e5530eaf2..e0140ba68 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -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; - /** - * 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; - /** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */ - buyerWeredaCodes: Record; - /** - * 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; /** * 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), diff --git a/apps/edr-freight-api/src/config/ethiopia-geo-codes.ts b/apps/edr-freight-api/src/config/ethiopia-geo-codes.ts deleted file mode 100644 index ca48a7c5f..000000000 --- a/apps/edr-freight-api/src/config/ethiopia-geo-codes.ts +++ /dev/null @@ -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 => { - const map: Record = {}; - 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 = 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 = buildMap((r) => [r[1], r[4]]); -export const ETHIOPIA_WOREDA_CODES: Record = 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]; -} diff --git a/apps/edr-freight-api/src/config/mor-location.resolver.spec.ts b/apps/edr-freight-api/src/config/mor-location.resolver.spec.ts new file mode 100644 index 000000000..0fc2607e2 --- /dev/null +++ b/apps/edr-freight-api/src/config/mor-location.resolver.spec.ts @@ -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, 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/, + ); + }); +}); diff --git a/apps/edr-freight-api/src/config/mor-location.resolver.ts b/apps/edr-freight-api/src/config/mor-location.resolver.ts new file mode 100644 index 000000000..72459db9b --- /dev/null +++ b/apps/edr-freight-api/src/config/mor-location.resolver.ts @@ -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 = { + 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(); +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; + /** 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 `.", + ); + } + + // `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; + } +} diff --git a/apps/edr-freight-api/src/config/mor-locations.data.ts b/apps/edr-freight-api/src/config/mor-locations.data.ts new file mode 100644 index 000000000..f55cf4ad5 --- /dev/null +++ b/apps/edr-freight-api/src/config/mor-locations.data.ts @@ -0,0 +1,1181 @@ +/** + * GENERATED FILE — do not hand-edit. + * + * MoR EIMS location master (`EIMS_COUNTRY_REGION_VW`), the Ministry's own geographic reference data. + * Regenerate from a supplied workbook with: + * + * pnpm --filter @edr/freight-api eims:import-locations + * + * Values are reproduced verbatim from the Ministry sheet — original spelling, original casing, + * original numbering. Nothing here is cleaned up or renumbered: this file is the traceable copy of + * the source. Spelling compatibility between EDR/e-Trade names and MoR names belongs in + * `mor-location.resolver.ts`'s normalization and alias layer, never here. + */ + +/** `[COUNTRY_NO, COUNTRY_NAME, PARISH_NO, PARISH_NAME, CITY_NO, CITY_NAME, LOCALITY_NO, LOCALITY_DESC]` */ +export type MorLocationTuple = [number, string, number, string, number, string, number, string]; + +export const MOR_LOCATIONS: MorLocationTuple[] = [ + [70, "Ethiopia", 2, "OROMIA", 1, "DEBUB MIRAB SHOA", 401, "WELISO"], + [70, "Ethiopia", 2, "OROMIA", 1, "DEBUB MIRAB SHOA", 402, "ILLU"], + [70, "Ethiopia", 2, "OROMIA", 1, "DEBUB MIRAB SHOA", 403, "AMMEYA"], + [70, "Ethiopia", 2, "OROMIA", 1, "DEBUB MIRAB SHOA", 404, "WENCHI"], + [70, "Ethiopia", 2, "OROMIA", 1, "DEBUB MIRAB SHOA", 405, "BECHO"], + [70, "Ethiopia", 2, "OROMIA", 1, "DEBUB MIRAB SHOA", 406, "TOLE"], + [70, "Ethiopia", 2, "OROMIA", 1, "DEBUB MIRAB SHOA", 407, "DAWO"], + [70, "Ethiopia", 2, "OROMIA", 1, "DEBUB MIRAB SHOA", 408, "KOKIR"], + [70, "Ethiopia", 2, "OROMIA", 1, "DEBUB MIRAB SHOA", 409, "KERSANA KONDALTIT"], + [70, "Ethiopia", 2, "OROMIA", 1, "DEBUB MIRAB SHOA", 601, "ALEMGENA CITY ADMIN"], + [70, "Ethiopia", 2, "OROMIA", 1, "DEBUB MIRAB SHOA", 602, "WELISO CITY ADMIN"], + [70, "Ethiopia", 2, "OROMIA", 1, "DEBUB MIRAB SHOA", 748, "GORO"], + [70, "Ethiopia", 2, "OROMIA", 1, "DEBUB MIRAB SHOA", 749, "SEDEN SODO"], + [70, "Ethiopia", 2, "OROMIA", 1, "DEBUB MIRAB SHOA", 750, "SODO DACHE"], + [70, "Ethiopia", 2, "OROMIA", 1, "DEBUB MIRAB SHOA", 897, "KERSA MALIMA"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 3, "KUYU"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 410, "WONCHI GIDA"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 411, "SULULTA MULA"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 412, "BEREH ALELTU"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 413, "KIMBIBIT"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 414, "WEREJERSO"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 415, "GRAR JERSO"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 416, "DERA"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 417, "DEGEM"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 418, "HADEBU ABOTE"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 419, "YAYA GULELENA DELI"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 420, "ABICHUNA GNEA"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 614, "FITCHE CITY ADMIN"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 615, "SENDAFA CITY ADMIN"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 751, "ALELTU"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 752, "DEBIRELIBANOS"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 753, "JIDDO"], + [70, "Ethiopia", 2, "OROMIA", 2, "SEMEN SHOA", 754, "WUCHALE"], + [70, "Ethiopia", 2, "OROMIA", 3, "BORENA", 4, "YABELO"], + [70, "Ethiopia", 2, "OROMIA", 3, "BORENA", 422, "TELITELE"], + [70, "Ethiopia", 2, "OROMIA", 3, "BORENA", 424, "ARARO"], + [70, "Ethiopia", 2, "OROMIA", 3, "BORENA", 425, "DIRE"], + [70, "Ethiopia", 2, "OROMIA", 3, "BORENA", 426, "MOYALE"], + [70, "Ethiopia", 2, "OROMIA", 3, "BORENA", 603, "YABELO CITY ADMIN"], + [70, "Ethiopia", 2, "OROMIA", 3, "BORENA", 679, "MIYO"], + [70, "Ethiopia", 2, "OROMIA", 3, "BORENA", 681, "BORBOR"], + [70, "Ethiopia", 2, "OROMIA", 3, "BORENA", 682, "DILLO"], + [70, "Ethiopia", 2, "OROMIA", 3, "BORENA", 1173, "GOMOLE"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 5, "LIBEN"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 428, "WADERA"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 429, "ODO SHAKISO"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 430, "BORE"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 431, "URAGA"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 432, "ADOLA"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 622, "NEGELE CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 684, "ANA HAMBELA"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 685, "DAMA"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 686, "GIRJA"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 687, "SEBBA BORU"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 688, "GORO DOLA"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 689, "ANA SORA"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 755, "ADOLA CITY ADMIN"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 756, "ANNA SORA"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 757, "GORO DOLO"], + [70, "Ethiopia", 2, "OROMIA", 4, "GUJI", 759, "SABA BORU"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 6, "GUTO WAYU"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 434, "ABAY CHEMEN"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 435, "JIMMA ARJO"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 436, "GIDA KIREMU"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 437, "SIBU SIRE"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 438, "LIMU"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 439, "NUNU KUMBA"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 440, "WAMA BONEYA"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 441, "AMURU"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 442, "HORO"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 443, "IBENTU"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 444, "GUDURU"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 445, "DIGA"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 446, "GUDEYA BILA"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 447, "SASIGA"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 448, "JARTE JARDEGA"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 449, "JIMMA GENETI"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 450, "ABE DONGORO"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 451, "LEKA DULECHA"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 452, "JIMMA RARE"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 591, "BILA SEYO"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 616, "NEKEMTE"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 705, "WAMA AGELO"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 706, "GUTO GIDDA"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 707, "EBANTU"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 708, "KIRAMU"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 709, "GOBBU SEYYO"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 710, "WAYYU TUQA"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 760, "BONEYYA BOSHE"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 761, "GIDA"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 762, "HARO LIMMU"], + [70, "Ethiopia", 2, "OROMIA", 5, "MISRAK WOLEGA", 763, "KIRAMU "], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 7, "SEYYO"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 19, "GIMBI"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 454, "ANFILO"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 455, "AYRA GULISO"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 456, "ALEM TEFERI"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 457, "LALO ASBI"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 458, "NEJO"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 459, "BOJI"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 460, "LALO KLE"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 461, "HARU"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 462, "DALE SEDI"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 463, "GENJI"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 464, "JIMMA HARO"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 465, "GAO DALE"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 466, "MENESIBU"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 467, "NOLEKABA"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 468, "BEGI"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 469, "GIDAMI"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 470, "YUBIDO"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 471, "JARISO"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 472, "HAWA WELEL"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 604, "GIMBI CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 764, "AYIRA "], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 765, "BABBO"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 766, "BODJI COQORSA"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 767, "BODJI DIRMAJI"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 768, "GULLISO"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 769, "HOMA"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 770, "KILTU KARRA"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 771, "KONDALA"], + [70, "Ethiopia", 2, "OROMIA", 6, "MIRAB WOLEGA", 772, "NEJO CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 7, "MIRAB HARARGE", 8, "CHIRO"], + [70, "Ethiopia", 2, "OROMIA", 7, "MIRAB HARARGE", 453, "TULO"], + [70, "Ethiopia", 2, "OROMIA", 7, "MIRAB HARARGE", 473, "MESO"], + [70, "Ethiopia", 2, "OROMIA", 7, "MIRAB HARARGE", 474, "KUNI"], + [70, "Ethiopia", 2, "OROMIA", 7, "MIRAB HARARGE", 475, "HABRO"], + [70, "Ethiopia", 2, "OROMIA", 7, "MIRAB HARARGE", 476, "DOBA"], + [70, "Ethiopia", 2, "OROMIA", 7, "MIRAB HARARGE", 477, "MESELA"], + [70, "Ethiopia", 2, "OROMIA", 7, "MIRAB HARARGE", 478, "DARO LEBU"], + [70, "Ethiopia", 2, "OROMIA", 7, "MIRAB HARARGE", 479, "GUBA KORCHA"], + [70, "Ethiopia", 2, "OROMIA", 7, "MIRAB HARARGE", 480, "ANCHER"], + [70, "Ethiopia", 2, "OROMIA", 7, "MIRAB HARARGE", 481, "BOKE"], + [70, "Ethiopia", 2, "OROMIA", 7, "MIRAB HARARGE", 605, "CHIRO CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 7, "MIRAB HARARGE", 773, "BEDDESSA CITY ADMIN"], + [70, "Ethiopia", 2, "OROMIA", 7, "MIRAB HARARGE", 774, "GEMMECHIS"], + [70, "Ethiopia", 2, "OROMIA", 7, "MIRAB HARARGE", 775, "HAWI GUDINA"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 9, "HAROMAYA"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 482, "KERSA"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 483, "META"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 484, "DEDER"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 485, "KOMBOLCHA"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 486, "GURSUM"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 487, "MELKA BELO"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 488, "JARSO"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 489, "GURAWA"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 490, "FEDISS"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 491, "GOLE ODA"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 492, "MAYU"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 493, "GORO GUTU"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 494, "BEDENO"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 495, "BABILE"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 496, "KORFA CHELE"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 617, "AWEDAY CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 696, "HAROMAYA CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 697, "DEDER CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 698, "CHINAKSEN"], + [70, "Ethiopia", 2, "OROMIA", 8, "MISRAK HARARGE", 699, "MIDAGA"], + [70, "Ethiopia", 2, "OROMIA", 9, "ROBE", 10, "ASSIGNED IN THE FUTURE"], + [70, "Ethiopia", 2, "OROMIA", 12, "MIRAB ARSII", 632, "ADEBA"], + [70, "Ethiopia", 2, "OROMIA", 12, "MIRAB ARSII", 633, "DODOLA"], + [70, "Ethiopia", 2, "OROMIA", 12, "MIRAB ARSII", 635, "KOKOSA"], + [70, "Ethiopia", 2, "OROMIA", 12, "MIRAB ARSII", 636, "QORE"], + [70, "Ethiopia", 2, "OROMIA", 12, "MIRAB ARSII", 637, "GEDEB ASASA"], + [70, "Ethiopia", 2, "OROMIA", 12, "MIRAB ARSII", 638, "SHALA"], + [70, "Ethiopia", 2, "OROMIA", 12, "MIRAB ARSII", 639, "KOFALE"], + [70, "Ethiopia", 2, "OROMIA", 12, "MIRAB ARSII", 640, "SHASHEMANE"], + [70, "Ethiopia", 2, "OROMIA", 12, "MIRAB ARSII", 641, "ARSI NEGELLE"], + [70, "Ethiopia", 2, "OROMIA", 12, "MIRAB ARSII", 642, "SIRARO"], + [70, "Ethiopia", 2, "OROMIA", 12, "MIRAB ARSII", 778, "DODOLA CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 12, "MIRAB ARSII", 779, "NENSEBO "], + [70, "Ethiopia", 2, "OROMIA", 12, "MIRAB ARSII", 780, "SHASHEMENE CITY ADM"], + [70, "Ethiopia", 2, "OROMIA", 13, "QELLEM WOLLEGA", 643, "ANFILO"], + [70, "Ethiopia", 2, "OROMIA", 13, "QELLEM WOLLEGA", 644, "DALE SADI"], + [70, "Ethiopia", 2, "OROMIA", 13, "QELLEM WOLLEGA", 645, "DALE WABERA"], + [70, "Ethiopia", 2, "OROMIA", 13, "QELLEM WOLLEGA", 646, "GAWO DALLE"], + [70, "Ethiopia", 2, "OROMIA", 13, "QELLEM WOLLEGA", 647, "GIDAMI"], + [70, "Ethiopia", 2, "OROMIA", 13, "QELLEM WOLLEGA", 648, "HAWA GELAN"], + [70, "Ethiopia", 2, "OROMIA", 13, "QELLEM WOLLEGA", 649, "JIMA HORO"], + [70, "Ethiopia", 2, "OROMIA", 13, "QELLEM WOLLEGA", 650, "LALO KILE"], + [70, "Ethiopia", 2, "OROMIA", 13, "QELLEM WOLLEGA", 651, "SAYO"], + [70, "Ethiopia", 2, "OROMIA", 13, "QELLEM WOLLEGA", 652, "YEMALOGI WELEL"], + [70, "Ethiopia", 2, "OROMIA", 13, "QELLEM WOLLEGA", 781, "DEMBIDOLLO CITY ADMIN"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 21, "ADAMA"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 377, "AKAKI"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 378, "LUME"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 379, "ADA"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 380, "SHASHEMENE"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 381, "ADAMITULU"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 382, "DUGDA BORA"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 383, "ARSI NEGELE"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 384, "BOSET"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 385, "FENTALE"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 386, "SIRARO"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 387, "GMBCHU"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 606, "ADAMA CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 618, "BISHOFTU CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 620, "SHASHEMENE CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 700, "MOJO TOWN ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 701, "BATU TOWN ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 702, "METEHARA CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 703, "BORA"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 704, "GELEAN CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 782, "BORA "], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 783, "DUGDA "], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 784, "LIBAN CHUKALA"], + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 785, "METAHARA CITY ADMIN"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 1, "AMBO"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 388, "DANDI"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 389, "CHELIA"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 390, "DIRRE INCHINI"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 391, "JELDU"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 392, "EJERE"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 393, "NONO"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 394, "BAKO"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 395, "MEDAKEY"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 396, "WELMERA"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 397, "DANO"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 398, "GINDEBERT"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 399, "ADA BERGA"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 400, "META ROBI"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 607, "AMBO CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 608, "HOLLETA CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 861, "ABUNA GINDEBERET"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 862, "ILFATA"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 863, "ILU GELAN"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 864, "JIBAT"], + [70, "Ethiopia", 2, "OROMIA", 62, "MIRAB SHOA", 865, "TOKKE KUTAYE"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 497, "DEDO"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 498, "GERA"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 499, "GOMA"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 500, "LIMUKOSA"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 501, "MANA"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 502, "OMO NADA"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 503, "KERSA"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 504, "SEKA CHOKORSA"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 505, "TIRO AFETA"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 506, "LIMU SEKA"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 507, "SOKORU"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 508, "SIGMO"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 509, "SETEMA"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 609, "JIMMA CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 884, "AGARO CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 886, "GUMAY"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 887, "NONNO BENJA"], + [70, "Ethiopia", 2, "OROMIA", 72, "JIMMA ZONE", 888, "SHABE"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 510, "METU"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 514, "HALU BURE"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 516, "DARIMU"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 519, "ALLE"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 520, "YAYU HURUMU"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 521, "SALE NONO"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 522, "ALGE SECHI"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 610, "METU CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 666, "BILO NOPHA"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 667, "DORENI"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 668, "DIDO"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 690, "HURUMU"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 691, "BURE"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 692, "BECHO"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 693, "NONNO"], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 890, "BURE "], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 891, "HALU "], + [70, "Ethiopia", 2, "OROMIA", 73, "ILLUBABOR", 892, "YAYU "], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 523, "ROBE"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 524, "DODOTANA SRE"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 525, "MERTI"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 526, "GEDEB"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 527, "GOLOLICHA"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 528, "TIYO"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 529, "LIMUNA BILBILO"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 530, "MUNESA"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 531, "TENA"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 532, "SERU"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 533, "KOFELE"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 534, "JEJU"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 535, "CHOLE"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 536, "ASAKO"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 537, "DIKSI"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 538, "DIGELUNA TIYU"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 539, "ZWAY DUGDA"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 540, "HETOSA"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 541, "LODEHETOSA"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 542, "SUDE"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 543, "AMINIYA"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 544, "SHIRKA"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 612, "ASSELA CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 654, "GUNA"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 669, "BOKOJI CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 670, "ENKOLO WABE"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 671, "SIRE"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 672, "BALE GASARA"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 893, "DODOTA"], + [70, "Ethiopia", 2, "OROMIA", 74, "ARSI", 1196, "SHENAN KOLU"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 545, "SINANA DINISHO"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 546, "GOBA"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 547, "DODOLA"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 548, "ADABA"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 549, "GINIR"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 550, "BERBERE"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 551, "AGARFA"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 552, "GASERA"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 553, "GORO"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 554, "GOLOLCHA"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 555, "MENA ANGATU"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 556, "KOKOSA"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 557, "NANSEBO"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 558, "SEWENA"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 559, "LEGEHIDA"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 560, "RAYTU"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 561, "GURADAMOLE"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 562, "MEDAWELABU"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 621, "ROBE CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 673, "GOBBA CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 674, "DAWWE SERAR"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 675, "HARENNA BULUK"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 676, "DINSHO"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 677, "DAWWE KACHEN"], + [70, "Ethiopia", 2, "OROMIA", 75, "BALE", 894, "SINANA "], + [70, "Ethiopia", 2, "OROMIA", 83, "MIRAB ARSI", 634, "NANSEBO"], + [70, "Ethiopia", 2, "OROMIA", 84, "Horo guduru Wollegga", 656, "ABBE DONGORO"], + [70, "Ethiopia", 2, "OROMIA", 84, "Horo guduru Wollegga", 657, "SHAMBU CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 84, "Horo guduru Wollegga", 658, "HORO"], + [70, "Ethiopia", 2, "OROMIA", 84, "Horo guduru Wollegga", 659, "JIMMA RARE"], + [70, "Ethiopia", 2, "OROMIA", 84, "Horo guduru Wollegga", 660, "ABBAY CHOMAN"], + [70, "Ethiopia", 2, "OROMIA", 84, "Horo guduru Wollegga", 661, "GUDURU"], + [70, "Ethiopia", 2, "OROMIA", 84, "Horo guduru Wollegga", 662, "IMBABO"], + [70, "Ethiopia", 2, "OROMIA", 84, "Horo guduru Wollegga", 663, "JIMMA GENET"], + [70, "Ethiopia", 2, "OROMIA", 84, "Horo guduru Wollegga", 664, "AMURU JARTE"], + [70, "Ethiopia", 2, "OROMIA", 84, "Horo guduru Wollegga", 665, "JARTE JARDEGA"], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 619, "DUKEM TOWN ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 909, "Akaki woreda "], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 910, "BARAH"], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 911, "BURAYYU TOWN ADMIN"], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 912, "Dukem Town Administration"], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 913, "Gelan Town Administration"], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 914, "Holleta Town Administration"], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 915, "LEGATAFO TOWN ADMIN"], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 916, "MULLO"], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 971, "Sabeta Hawas"], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 972, "Sebeta Town Administration"], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 973, "Sendafa Town Administration"], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 974, "Sululta "], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 975, "SULULTA TOWN ADMIN"], + [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 976, "Wal-Mera"], + [70, "Ethiopia", 2, "OROMIA", 94, "OROMIYA-BUNO-BEDELE-ZONE", 247, "BORICHA"], + [70, "Ethiopia", 2, "OROMIA", 94, "OROMIYA-BUNO-BEDELE-ZONE", 885, "CHORA"], + [70, "Ethiopia", 2, "OROMIA", 95, "OROMIYA-WEST-GUJI-ZONE", 421, "GELANA"], + [70, "Ethiopia", 2, "OROMIA", 95, "OROMIYA-WEST-GUJI-ZONE", 423, "BULAHORA"], + [70, "Ethiopia", 2, "OROMIA", 95, "OROMIYA-WEST-GUJI-ZONE", 427, "ABEYA"], + [70, "Ethiopia", 2, "OROMIA", 95, "OROMIYA-WEST-GUJI-ZONE", 433, "KERRCHA"], + [70, "Ethiopia", 2, "OROMIA", 95, "OROMIYA-WEST-GUJI-ZONE", 678, "DUGDA DAWWA"], + [70, "Ethiopia", 2, "OROMIA", 95, "OROMIYA-WEST-GUJI-ZONE", 680, "MELKA SODDA"], + [70, "Ethiopia", 2, "OROMIA", 95, "OROMIYA-WEST-GUJI-ZONE", 758, "HAMBELA WAMNA"], + [70, "Ethiopia", 2, "OROMIA", 95, "OROMIYA-WEST-GUJI-ZONE", 1194, "SURO BARGUDA"], + [70, "Ethiopia", 2, "OROMIA", 98, "BUNO BEDELE ZONE", 511, "BEDELE"], + [70, "Ethiopia", 2, "OROMIA", 98, "BUNO BEDELE ZONE", 512, "GECHI"], + [70, "Ethiopia", 2, "OROMIA", 98, "BUNO BEDELE ZONE", 513, "BORECHA"], + [70, "Ethiopia", 2, "OROMIA", 98, "BUNO BEDELE ZONE", 515, "CHORA"], + [70, "Ethiopia", 2, "OROMIA", 98, "BUNO BEDELE ZONE", 517, "DIDESSA"], + [70, "Ethiopia", 2, "OROMIA", 98, "BUNO BEDELE ZONE", 518, "DIGA"], + [70, "Ethiopia", 2, "OROMIA", 98, "BUNO BEDELE ZONE", 613, "CHEWAKA"], + [70, "Ethiopia", 2, "OROMIA", 98, "BUNO BEDELE ZONE", 694, "DABO HANA"], + [70, "Ethiopia", 2, "OROMIA", 98, "BUNO BEDELE ZONE", 695, "MEKKO"], + [70, "Ethiopia", 2, "OROMIA", 98, "BUNO BEDELE ZONE", 889, "BEDELLE CITY ADMINISTRATION"], + [70, "Ethiopia", 2, "OROMIA", 100, "WEST GUJI ZONE", 1199, "BIRBIRSA KOJOWA"], + [70, "Ethiopia", 2, "OROMIA", 106, "OROMIA", 2, "ALEMGENA"], + [70, "Ethiopia", 2, "OROMIA", 121, "SHEGER CITY", 1288, "MERTU 1"], + [70, "Ethiopia", 2, "OROMIA", 123, "EAST BORENA ZONE", 1291, "OBORSO"], + [70, "Ethiopia", 2, "OROMIA", 124, "BISHOFTU CITY ADMIN", 1292, "DIBAYU SUBCITY"], + [70, "Ethiopia", 2, "OROMIA", 125, "SHEGER CITY ADMIN", 1293, "MELKA NONO SUB CITY"], + [70, "Ethiopia", 3, "TIGRAY", 56, "MEAKELLAY", 13, "TAHITY MAICHEW"], + [70, "Ethiopia", 3, "TIGRAY", 56, "MEAKELLAY", 342, "DEGUA TENBEN"], + [70, "Ethiopia", 3, "TIGRAY", 56, "MEAKELLAY", 343, "LAILAY MICHEW"], + [70, "Ethiopia", 3, "TIGRAY", 56, "MEAKELLAY", 344, "TANKWA ABERGELE"], + [70, "Ethiopia", 3, "TIGRAY", 56, "MEAKELLAY", 345, "KOLA TENBEN"], + [70, "Ethiopia", 3, "TIGRAY", 56, "MEAKELLAY", 346, "NAIDAR ADAT"], + [70, "Ethiopia", 3, "TIGRAY", 56, "MEAKELLAY", 347, "MEREB LEHE"], + [70, "Ethiopia", 3, "TIGRAY", 56, "MEAKELLAY", 348, "WEREI"], + [70, "Ethiopia", 3, "TIGRAY", 56, "MEAKELLAY", 349, "ADOWA TOWN ADMIN"], + [70, "Ethiopia", 3, "TIGRAY", 56, "MEAKELLAY", 350, "AHIFEROM"], + [70, "Ethiopia", 3, "TIGRAY", 56, "MEAKELLAY", 592, "AXUM TOWN ADMIN"], + [70, "Ethiopia", 3, "TIGRAY", 56, "MEAKELLAY", 626, "GETER ADWA"], + [70, "Ethiopia", 3, "TIGRAY", 56, "MEAKELLAY", 627, "ABIY ADI TOWN ADMIN"], + [70, "Ethiopia", 3, "TIGRAY", 57, "MISRAK", 351, "GULOMECHA"], + [70, "Ethiopia", 3, "TIGRAY", 57, "MISRAK", 352, "SAISI TSAIDA IMBA"], + [70, "Ethiopia", 3, "TIGRAY", 57, "MISRAK", 353, "GANTA AFESHUM"], + [70, "Ethiopia", 3, "TIGRAY", 57, "MISRAK", 354, "ASTIBI WENBERTA"], + [70, "Ethiopia", 3, "TIGRAY", 57, "MISRAK", 355, "HAWZEN"], + [70, "Ethiopia", 3, "TIGRAY", 57, "MISRAK", 356, "WKIRO"], + [70, "Ethiopia", 3, "TIGRAY", 57, "MISRAK", 357, "EROB"], + [70, "Ethiopia", 3, "TIGRAY", 57, "MISRAK", 593, "ADIGRAT TOWN ADMIN"], + [70, "Ethiopia", 3, "TIGRAY", 57, "MISRAK", 628, "KLETE AWLAELO"], + [70, "Ethiopia", 3, "TIGRAY", 58, "MIRABAWI", 358, "KWAFTA HUMERA"], + [70, "Ethiopia", 3, "TIGRAY", 58, "MIRABAWI", 359, "TSEGEDE"], + [70, "Ethiopia", 3, "TIGRAY", 58, "MIRABAWI", 360, "WELKAYIT"], + [70, "Ethiopia", 3, "TIGRAY", 58, "MIRABAWI", 594, "HUMERA TOWN ADMIN"], + [70, "Ethiopia", 3, "TIGRAY", 59, "SEMEN MIRABAWI", 361, "LAILAY ADIYABO"], + [70, "Ethiopia", 3, "TIGRAY", 59, "SEMEN MIRABAWI", 362, "MDEBAY ZANA"], + [70, "Ethiopia", 3, "TIGRAY", 59, "SEMEN MIRABAWI", 363, "TAHITAY ADIYABO"], + [70, "Ethiopia", 3, "TIGRAY", 59, "SEMEN MIRABAWI", 364, "TSELEMTI"], + [70, "Ethiopia", 3, "TIGRAY", 59, "SEMEN MIRABAWI", 365, "ASEGEDE TSIMBLA"], + [70, "Ethiopia", 3, "TIGRAY", 59, "SEMEN MIRABAWI", 595, "SHIRARO TOWN ADMIN"], + [70, "Ethiopia", 3, "TIGRAY", 59, "SEMEN MIRABAWI", 596, "TAHITAY KORARO"], + [70, "Ethiopia", 3, "TIGRAY", 59, "SEMEN MIRABAWI", 597, "INDASILASSIE TOWN ADMIN"], + [70, "Ethiopia", 3, "TIGRAY", 70, "DEBUBAWI ZONE", 366, "INIDAMOHENI"], + [70, "Ethiopia", 3, "TIGRAY", 70, "DEBUBAWI ZONE", 367, "INDERTA"], + [70, "Ethiopia", 3, "TIGRAY", 70, "DEBUBAWI ZONE", 368, "SAMRA SAHARTI"], + [70, "Ethiopia", 3, "TIGRAY", 70, "DEBUBAWI ZONE", 369, "ALAJE"], + [70, "Ethiopia", 3, "TIGRAY", 70, "DEBUBAWI ZONE", 370, "ALAMATA TOWN ADMIN"], + [70, "Ethiopia", 3, "TIGRAY", 70, "DEBUBAWI ZONE", 371, "OFLA"], + [70, "Ethiopia", 3, "TIGRAY", 70, "DEBUBAWI ZONE", 372, "RAYA AZEBO"], + [70, "Ethiopia", 3, "TIGRAY", 70, "DEBUBAWI ZONE", 598, "MAICHEW TOWN ADMIN"], + [70, "Ethiopia", 3, "TIGRAY", 70, "DEBUBAWI ZONE", 599, "KOREM TOWN ADMINISTRATION"], + [70, "Ethiopia", 3, "TIGRAY", 70, "DEBUBAWI ZONE", 629, "GETER ALAMATA"], + [70, "Ethiopia", 3, "TIGRAY", 70, "DEBUBAWI ZONE", 630, "HINTALO WAJRAT"], + [70, "Ethiopia", 3, "TIGRAY", 70, "DEBUBAWI ZONE", 1193, "SAMRE SEHARTI"], + [70, "Ethiopia", 3, "TIGRAY", 71, "MEKELE", 373, "SEMENE WORDA"], + [70, "Ethiopia", 3, "TIGRAY", 71, "MEKELE", 611, "DEBUB WEREDA"], + [70, "Ethiopia", 3, "TIGRAY", 71, "MEKELE", 623, "KUHA WOREDA"], + [70, "Ethiopia", 3, "TIGRAY", 71, "MEKELE", 879, "AIDER"], + [70, "Ethiopia", 3, "TIGRAY", 71, "MEKELE", 880, "ADI HAKI"], + [70, "Ethiopia", 3, "TIGRAY", 71, "MEKELE", 881, "HADNET"], + [70, "Ethiopia", 3, "TIGRAY", 71, "MEKELE", 882, "HAWULTI"], + [70, "Ethiopia", 3, "TIGRAY", 71, "MEKELE", 883, "KEDAMAY WOYANE"], + [70, "Ethiopia", 3, "TIGRAY", 99, "SOUTH EASTERN ZONE", 1192, "SAMRE SEHARTI"], + [70, "Ethiopia", 4, "AFAR", 42, "ZONE 1 (AYSSAITA)", 15, "AYSSAITA"], + [70, "Ethiopia", 4, "AFAR", 42, "ZONE 1 (AYSSAITA)", 22, "AFAMBO"], + [70, "Ethiopia", 4, "AFAR", 42, "ZONE 1 (AYSSAITA)", 23, "DUBTI"], + [70, "Ethiopia", 4, "AFAR", 42, "ZONE 1 (AYSSAITA)", 24, "MILE"], + [70, "Ethiopia", 4, "AFAR", 42, "ZONE 1 (AYSSAITA)", 25, "ELIDAAR"], + [70, "Ethiopia", 4, "AFAR", 42, "ZONE 1 (AYSSAITA)", 26, "CHIFRA"], + [70, "Ethiopia", 4, "AFAR", 42, "ZONE 1 (AYSSAITA)", 816, "ADAER"], + [70, "Ethiopia", 4, "AFAR", 42, "ZONE 1 (AYSSAITA)", 817, "KURI"], + [70, "Ethiopia", 4, "AFAR", 42, "ZONE 1 (AYSSAITA)", 1190, "SEMERA - LOGIA CITY ADMINISTRATION"], + [70, "Ethiopia", 4, "AFAR", 43, "ZONE 2 (ABBALLA)", 27, "BERHALLE"], + [70, "Ethiopia", 4, "AFAR", 43, "ZONE 2 (ABBALLA)", 28, "ABBALLA"], + [70, "Ethiopia", 4, "AFAR", 43, "ZONE 2 (ABBALLA)", 29, "KUNEBBA"], + [70, "Ethiopia", 4, "AFAR", 43, "ZONE 2 (ABBALLA)", 30, "AFFDERA"], + [70, "Ethiopia", 4, "AFAR", 43, "ZONE 2 (ABBALLA)", 31, "IREBTI"], + [70, "Ethiopia", 4, "AFAR", 43, "ZONE 2 (ABBALLA)", 32, "MEGALLE"], + [70, "Ethiopia", 4, "AFAR", 43, "ZONE 2 (ABBALLA)", 33, "DALLOL"], + [70, "Ethiopia", 4, "AFAR", 43, "ZONE 2 (ABBALLA)", 818, "BIDU"], + [70, "Ethiopia", 4, "AFAR", 44, "ZONE 3 (SIDAHAFAGE)", 34, "AMMIBARA"], + [70, "Ethiopia", 4, "AFAR", 44, "ZONE 3 (SIDAHAFAGE)", 35, "GEWANE"], + [70, "Ethiopia", 4, "AFAR", 44, "ZONE 3 (SIDAHAFAGE)", 36, "BUREMUDAYTU"], + [70, "Ethiopia", 4, "AFAR", 44, "ZONE 3 (SIDAHAFAGE)", 37, "AWASH"], + [70, "Ethiopia", 4, "AFAR", 44, "ZONE 3 (SIDAHAFAGE)", 38, "DULECHA"], + [70, "Ethiopia", 4, "AFAR", 44, "ZONE 3 (SIDAHAFAGE)", 39, "GACHENNE"], + [70, "Ethiopia", 4, "AFAR", 44, "ZONE 3 (SIDAHAFAGE)", 819, "ARGOBA"], + [70, "Ethiopia", 4, "AFAR", 44, "ZONE 3 (SIDAHAFAGE)", 1191, "AWASH CITY ADMINISTRATION"], + [70, "Ethiopia", 4, "AFAR", 64, "ZONE4 (KELEWAN)", 40, "KELEWAN"], + [70, "Ethiopia", 4, "AFAR", 64, "ZONE4 (KELEWAN)", 41, "YALLO"], + [70, "Ethiopia", 4, "AFAR", 64, "ZONE4 (KELEWAN)", 42, "AWRRA"], + [70, "Ethiopia", 4, "AFAR", 64, "ZONE4 (KELEWAN)", 43, "TERRU"], + [70, "Ethiopia", 4, "AFAR", 64, "ZONE4 (KELEWAN)", 44, "EWAA"], + [70, "Ethiopia", 4, "AFAR", 64, "ZONE4 (KELEWAN)", 908, "GULINA"], + [70, "Ethiopia", 4, "AFAR", 65, "ZONE 5 (DALLIFAGEA)", 45, "DALLIFAGEA"], + [70, "Ethiopia", 4, "AFAR", 65, "ZONE 5 (DALLIFAGEA)", 46, "ARTUMMA"], + [70, "Ethiopia", 4, "AFAR", 65, "ZONE 5 (DALLIFAGEA)", 47, "SEMUROBI"], + [70, "Ethiopia", 4, "AFAR", 65, "ZONE 5 (DALLIFAGEA)", 48, "DEWWEA"], + [70, "Ethiopia", 4, "AFAR", 65, "ZONE 5 (DALLIFAGEA)", 49, "TELALAK"], + [70, "Ethiopia", 4, "AFAR", 65, "ZONE 5 (DALLIFAGEA)", 866, "HADELE ELA"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 39, "ASOSA", 16, "ASSOSA"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 39, "ASOSA", 163, "KURMUK"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 39, "ASOSA", 164, "BANBASI"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 39, "ASOSA", 165, "MENGE"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 39, "ASOSA", 166, "SHERKOLE"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 39, "ASOSA", 167, "KOMOSHA"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 39, "ASOSA", 168, "ODABUL DIGLU"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 39, "ASOSA", 169, "PAWE SPECIAL WORDA"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 39, "ASOSA", 170, "MAO KOMO SPECIAL WORDA"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 39, "ASOSA", 1201, "ASOSSA TOWN ADMINISTRATION"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 40, "KAMSH", 171, "KAMASH"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 40, "KAMSH", 172, "SEDAL"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 40, "KAMSH", 173, "BELODJAGANFOY"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 40, "KAMSH", 174, "AGALOMITI"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 40, "KAMSH", 175, "YASO"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 40, "KAMSH", 1202, "KAMASHI TOWN ADMINSTRATION"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 41, "METEKEL", 176, "MAMDURA"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 41, "METEKEL", 177, "DANGUR"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 41, "METEKEL", 178, "WENBERA"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 41, "METEKEL", 179, "DUBATE"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 41, "METEKEL", 180, "BULEN"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 41, "METEKEL", 181, "GUBA"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 41, "METEKEL", 815, "PAWEIE"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 41, "METEKEL", 1203, "GELGEL BELES TOWN ADMINSTRATION"], + [70, "Ethiopia", 5, "BENSHANGUL GUMUZ", 89, "NO ZONE/MAO KOMO", 896, "MAO KOMO"], + [70, "Ethiopia", 6, "SOMALI", 30, "SITI ZONE", 12, "SHINELE"], + [70, "Ethiopia", 6, "SOMALI", 30, "SITI ZONE", 197, "DENBEL"], + [70, "Ethiopia", 6, "SOMALI", 30, "SITI ZONE", 198, "ERAR"], + [70, "Ethiopia", 6, "SOMALI", 30, "SITI ZONE", 199, "AYSHA"], + [70, "Ethiopia", 6, "SOMALI", 30, "SITI ZONE", 200, "AFDEM"], + [70, "Ethiopia", 6, "SOMALI", 30, "SITI ZONE", 201, "MIESO"], + [70, "Ethiopia", 6, "SOMALI", 30, "SITI ZONE", 811, "MA'YS"], + [70, "Ethiopia", 6, "SOMALI", 30, "SITI ZONE", 898, "NO WOREDA-1412"], + [70, "Ethiopia", 6, "SOMALI", 30, "SITI ZONE", 899, "NO WOREDA-1413"], + [70, "Ethiopia", 6, "SOMALI", 30, "SITI ZONE", 986, "Hadagale"], + [70, "Ethiopia", 6, "SOMALI", 30, "SITI ZONE", 988, "HADAGALE WOREDA"], + [70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 190, "JIJIGA"], + [70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 191, "KEBRI BEYAH"], + [70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 192, "AWUBERE"], + [70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 193, "HARSHIN"], + [70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 194, "BABILE"], + [70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 195, "GURSUM"], + [70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 196, "EJERSAGORO"], + [70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 980, "Tuli Guled"], + [70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 981, "Goljano"], + [70, "Ethiopia", 6, "SOMALI", 31, "FAAFAN ZONE", 983, "GOLJANO WOREDA"], + [70, "Ethiopia", 6, "SOMALI", 32, "KORAHI", 202, "KEBRIDAHAR"], + [70, "Ethiopia", 6, "SOMALI", 32, "KORAHI", 203, "WEYIN"], + [70, "Ethiopia", 6, "SOMALI", 32, "KORAHI", 204, "SHYKOSH"], + [70, "Ethiopia", 6, "SOMALI", 32, "KORAHI", 205, "SHILABO"], + [70, "Ethiopia", 6, "SOMALI", 32, "KORAHI", 900, "DOBOWEYN"], + [70, "Ethiopia", 6, "SOMALI", 32, "KORAHI", 996, "Marsin"], + [70, "Ethiopia", 6, "SOMALI", 33, "SHABEELE ZONE", 206, "GODE"], + [70, "Ethiopia", 6, "SOMALI", 33, "SHABEELE ZONE", 207, "KELAFO"], + [70, "Ethiopia", 6, "SOMALI", 33, "SHABEELE ZONE", 208, "MUSTEHIL"], + [70, "Ethiopia", 6, "SOMALI", 33, "SHABEELE ZONE", 209, "FERFEF"], + [70, "Ethiopia", 6, "SOMALI", 33, "SHABEELE ZONE", 210, "DANAN"], + [70, "Ethiopia", 6, "SOMALI", 33, "SHABEELE ZONE", 211, "ADADA"], + [70, "Ethiopia", 6, "SOMALI", 33, "SHABEELE ZONE", 212, "EMAYBERE"], + [70, "Ethiopia", 6, "SOMALI", 33, "SHABEELE ZONE", 901, "ADADLEY"], + [70, "Ethiopia", 6, "SOMALI", 33, "SHABEELE ZONE", 902, "EAST EMAY"], + [70, "Ethiopia", 6, "SOMALI", 33, "SHABEELE ZONE", 992, "Elwayn"], + [70, "Ethiopia", 6, "SOMALI", 33, "SHABEELE ZONE", 993, "Ber'ano"], + [70, "Ethiopia", 6, "SOMALI", 33, "SHABEELE ZONE", 1184, "ABAGOROW"], + [70, "Ethiopia", 6, "SOMALI", 34, "JARAR ZONE", 213, "DEGAHABUR"], + [70, "Ethiopia", 6, "SOMALI", 34, "JARAR ZONE", 214, "AWARE"], + [70, "Ethiopia", 6, "SOMALI", 34, "JARAR ZONE", 215, "DGAHMEDOW"], + [70, "Ethiopia", 6, "SOMALI", 34, "JARAR ZONE", 216, "GASHAMO"], + [70, "Ethiopia", 6, "SOMALI", 34, "JARAR ZONE", 812, "GUNAGADO"], + [70, "Ethiopia", 6, "SOMALI", 34, "JARAR ZONE", 982, "Birkod"], + [70, "Ethiopia", 6, "SOMALI", 34, "JARAR ZONE", 984, "Daroor"], + [70, "Ethiopia", 6, "SOMALI", 34, "JARAR ZONE", 985, "Ararso"], + [70, "Ethiopia", 6, "SOMALI", 34, "JARAR ZONE", 1014, "YOALE"], + [70, "Ethiopia", 6, "SOMALI", 35, "NOGOB ZONE", 217, "FIK"], + [70, "Ethiopia", 6, "SOMALI", 35, "NOGOB ZONE", 218, "SEGEG"], + [70, "Ethiopia", 6, "SOMALI", 35, "NOGOB ZONE", 219, "HAMERO"], + [70, "Ethiopia", 6, "SOMALI", 35, "NOGOB ZONE", 220, "DUHUN"], + [70, "Ethiopia", 6, "SOMALI", 35, "NOGOB ZONE", 221, "GERBO"], + [70, "Ethiopia", 6, "SOMALI", 35, "NOGOB ZONE", 222, "LGEHAD"], + [70, "Ethiopia", 6, "SOMALI", 35, "NOGOB ZONE", 223, "SELHAD"], + [70, "Ethiopia", 6, "SOMALI", 35, "NOGOB ZONE", 813, "MAYAMULUKO"], + [70, "Ethiopia", 6, "SOMALI", 35, "NOGOB ZONE", 903, "GAEBO"], + [70, "Ethiopia", 6, "SOMALI", 35, "NOGOB ZONE", 991, "Qubi"], + [70, "Ethiopia", 6, "SOMALI", 35, "NOGOB ZONE", 1168, "CEELWAYNE"], + [70, "Ethiopia", 6, "SOMALI", 36, "DOLLO ZONE", 224, "WARDER"], + [70, "Ethiopia", 6, "SOMALI", 36, "DOLLO ZONE", 225, "GELAD"], + [70, "Ethiopia", 6, "SOMALI", 36, "DOLLO ZONE", 226, "DANOD"], + [70, "Ethiopia", 6, "SOMALI", 36, "DOLLO ZONE", 227, "BOK"], + [70, "Ethiopia", 6, "SOMALI", 36, "DOLLO ZONE", 989, "Daratole"], + [70, "Ethiopia", 6, "SOMALI", 37, "AFDER", 228, "ELKERE"], + [70, "Ethiopia", 6, "SOMALI", 37, "AFDER", 229, "HARGELE"], + [70, "Ethiopia", 6, "SOMALI", 37, "AFDER", 230, "BARE"], + [70, "Ethiopia", 6, "SOMALI", 37, "AFDER", 231, "JERETI"], + [70, "Ethiopia", 6, "SOMALI", 37, "AFDER", 232, "IMAYEGELEBED"], + [70, "Ethiopia", 6, "SOMALI", 37, "AFDER", 233, "GORO BAKAKSA"], + [70, "Ethiopia", 6, "SOMALI", 37, "AFDER", 234, "GURDAMOLE"], + [70, "Ethiopia", 6, "SOMALI", 37, "AFDER", 814, "DOLOBAY"], + [70, "Ethiopia", 6, "SOMALI", 37, "AFDER", 904, "WEST EMAY"], + [70, "Ethiopia", 6, "SOMALI", 37, "AFDER", 994, "Qarsadula"], + [70, "Ethiopia", 6, "SOMALI", 37, "AFDER", 995, "Raso"], + [70, "Ethiopia", 6, "SOMALI", 37, "AFDER", 1186, "GODGOD"], + [70, "Ethiopia", 6, "SOMALI", 38, "LIBON", 235, "FILTU"], + [70, "Ethiopia", 6, "SOMALI", 38, "LIBON", 236, "DOLADEW"], + [70, "Ethiopia", 6, "SOMALI", 38, "LIBON", 237, "MOYALE"], + [70, "Ethiopia", 6, "SOMALI", 38, "LIBON", 238, "KEAHORE/HADAT/"], + [70, "Ethiopia", 6, "SOMALI", 38, "LIBON", 987, "Mubarak"], + [70, "Ethiopia", 6, "SOMALI", 38, "LIBON", 1015, "DEKA SUFTI"], + [70, "Ethiopia", 6, "SOMALI", 38, "LIBON", 1189, "BOQAL-MAY"], + [70, "Ethiopia", 6, "SOMALI", 93, "ERER ZONE", 1171, "FIIQ"], + [70, "Ethiopia", 7, "GAMBELA", 27, "NUER", 17, "GAMBELLA"], + [70, "Ethiopia", 7, "GAMBELA", 27, "NUER", 182, "EITANG"], + [70, "Ethiopia", 7, "GAMBELA", 27, "NUER", 183, "JIKAWO"], + [70, "Ethiopia", 7, "GAMBELA", 27, "NUER", 184, "AKOBO"], + [70, "Ethiopia", 7, "GAMBELA", 27, "NUER", 808, "LARE"], + [70, "Ethiopia", 7, "GAMBELA", 27, "NUER", 809, "MAKUEY"], + [70, "Ethiopia", 7, "GAMBELA", 27, "NUER", 979, "Wantuar"], + [70, "Ethiopia", 7, "GAMBELA", 28, "AGNUWAK", 185, "ABOBO"], + [70, "Ethiopia", 7, "GAMBELA", 28, "AGNUWAK", 186, "GOG"], + [70, "Ethiopia", 7, "GAMBELA", 28, "AGNUWAK", 188, "DIMA"], + [70, "Ethiopia", 7, "GAMBELA", 28, "AGNUWAK", 189, "JOR"], + [70, "Ethiopia", 7, "GAMBELA", 28, "AGNUWAK", 810, "GAMBELA"], + [70, "Ethiopia", 7, "GAMBELA", 87, "MEZENGER", 187, "GODERE"], + [70, "Ethiopia", 7, "GAMBELA", 87, "MEZENGER", 977, "MENGESH"], + [70, "Ethiopia", 7, "GAMBELA", 88, "NO ZONE/ETANG", 895, "ETANG "], + [70, "Ethiopia", 7, "GAMBELA", 90, "GAMBELLA TOWN ADMIN", 978, "GAMBELLA TOWN ADMIN"], + [70, "Ethiopia", 8, "HARARI", 60, "NO ZONE - HARARI", 18, "NO WOREDA-9"], + [70, "Ethiopia", 9, "SNNPRS", 10, "HALABA ZONE", 600, "ALABA CITY ADMINISTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 10, "HALABA ZONE", 1247, "WERA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 10, "HALABA ZONE", 1248, "ATOTI OULO WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 10, "HALABA ZONE", 1249, "WERA DIJO WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 11, "HAWASSA CITY ADMIN", 624, "NO WOREDA-193"], + [70, "Ethiopia", 9, "SNNPRS", 11, "HAWASSA CITY ADMIN", 776, "HAWASSA CITY ADM"], + [70, "Ethiopia", 9, "SNNPRS", 11, "HAWASSA CITY ADMIN", 777, "TULA SUB TOWN ADMIN"], + [70, "Ethiopia", 9, "SNNPRS", 18, "GEDIO", 248, "WENAGO"], + [70, "Ethiopia", 9, "SNNPRS", 18, "GEDIO", 249, "YIRGACHEFE"], + [70, "Ethiopia", 9, "SNNPRS", 18, "GEDIO", 250, "COCHERE"], + [70, "Ethiopia", 9, "SNNPRS", 18, "GEDIO", 251, "BULE"], + [70, "Ethiopia", 9, "SNNPRS", 18, "GEDIO", 566, "DILLA TOWN ADMIN"], + [70, "Ethiopia", 9, "SNNPRS", 18, "GEDIO", 567, "YIRGACHEFE TOWN ADMIN"], + [70, "Ethiopia", 9, "SNNPRS", 18, "GEDIO", 720, "GEDEB"], + [70, "Ethiopia", 9, "SNNPRS", 18, "GEDIO", 721, "DILLA ZURIA"], + [70, "Ethiopia", 9, "SNNPRS", 18, "GEDIO", 722, "WERABE CITY ADMINISTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 18, "GEDIO", 790, "DILA VICINITY"], + [70, "Ethiopia", 9, "SNNPRS", 18, "GEDIO", 1232, "GEDEB TOWN ADMINSTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 18, "GEDIO", 1233, "CHELELEKTU TOWN ADMINSTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 18, "GEDIO", 1234, "CHORSO MAZORIA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 18, "GEDIO", 1235, "RAPE WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 19, "KENBATA TENBARO", 252, "KEDIDA GAMELA"], + [70, "Ethiopia", 9, "SNNPRS", 19, "KENBATA TENBARO", 253, "QACHA BIRA"], + [70, "Ethiopia", 9, "SNNPRS", 19, "KENBATA TENBARO", 254, "ANGACHA"], + [70, "Ethiopia", 9, "SNNPRS", 19, "KENBATA TENBARO", 255, "TENBARO"], + [70, "Ethiopia", 9, "SNNPRS", 19, "KENBATA TENBARO", 586, "DURAME TOWN ADMIN"], + [70, "Ethiopia", 9, "SNNPRS", 19, "KENBATA TENBARO", 655, "DOYOGENA"], + [70, "Ethiopia", 9, "SNNPRS", 19, "KENBATA TENBARO", 727, "HADERO TOWN"], + [70, "Ethiopia", 9, "SNNPRS", 19, "KENBATA TENBARO", 728, "DEMBOYA"], + [70, "Ethiopia", 9, "SNNPRS", 19, "KENBATA TENBARO", 791, "HADARO & TUNTO"], + [70, "Ethiopia", 9, "SNNPRS", 19, "KENBATA TENBARO", 1036, "SHINSHICHO TOWN"], + [70, "Ethiopia", 9, "SNNPRS", 19, "KENBATA TENBARO", 1226, "ADILO ZURIA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 256, "SODO VICINTY"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 257, "DAMOT GALE"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 258, "DAMOT WOYIDA"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 259, "BOLOSO SORE"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 260, "OFFA"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 261, "KINDO KOYISHA"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 262, "HUMBO"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 570, "SODO TOWN ADMIN"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 579, "BODITY TOWN ADMIN"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 580, "ARCKA CITY ADMINISTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 743, "DAMOT SORE"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 744, "DAMOT FULASE"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 745, "BELESO BONBE"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 746, "KINDO DIDAYO"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 747, "DIGUNA FANGO"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 792, "BULOSO BOMBE"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 793, "DAGUNA FANGO"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 794, "DAMOT FULAS"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 795, "KINDO DEDAYE"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 1240, "TEBELA TOWN ADMINSTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 1241, "GUNUNO HAMUS TOWN ADMINSTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 1242, "GESUBA TOWN ADMINSTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 1243, "HOBICHA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 1244, "ABELA ABAYA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 1245, "BAYIRA KOYISHA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 20, "WOLAYTA", 1246, "KAWA KOYISHA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 263, "LIMU"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 264, "MISHA"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 265, "SORO"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 266, "BADEWACHO"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 267, "GIBE"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 268, "SHASHEGO"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 269, "DUNA"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 585, "HOSAINA TOWN ADMINISTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 724, "MIRAB BADEWACHO"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 725, "GOMBORA"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 726, "MISRAK BADEWACHO"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 796, "ANLEMO"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 797, "EAST BADEWACHO"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 798, "GONBERA"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 1035, "SHONE TOWN"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 1250, "GIMIBECHU TOWN ADMINSTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 1251, "JAJURA TOWN ADMINTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 1252, "AMEKA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 1253, "SIRARO BADEWACHO WEREDA"], + [70, "Ethiopia", 9, "SNNPRS", 21, "HADYA", 1254, "MIRAB SORO WOREDA "], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 270, "ARBAMINCH ZURIA"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 271, "MIRAB ABAYA"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 272, "BONKE"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 273, "KEMBA TWON ADMINSTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 274, "CHENCHA ZURIA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 275, "DEREMALO"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 276, "KUCHA"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 277, "GOFA ZURIA"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 280, "BOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 281, "DITA"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 581, "ARBAMINCH TOWN ADMINISTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 719, "DENBA GOFA"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 800, "GIZE GOFA"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 1218, "CHENCHA TOWN ADMINSTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 1219, "SELAMBER TOWN ADMINSTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 1220, "KEMBA ZURIYA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 1221, "GARDA MARTA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 1222, "GERESE WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 1223, "GACHO BABA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 1224, "KOGOTA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 1225, "KUCHA ALFA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 22, "GAMO ZONE", 1277, "BIRIBIR TOWN ADMINSTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 25, "DEBUB OMO", 300, "BAKO GAZER"], + [70, "Ethiopia", 9, "SNNPRS", 25, "DEBUB OMO", 301, "HAMER"], + [70, "Ethiopia", 9, "SNNPRS", 25, "DEBUB OMO", 302, "GELEB"], + [70, "Ethiopia", 9, "SNNPRS", 25, "DEBUB OMO", 303, "SELAMAGO"], + [70, "Ethiopia", 9, "SNNPRS", 25, "DEBUB OMO", 304, "GELILA"], + [70, "Ethiopia", 9, "SNNPRS", 25, "DEBUB OMO", 305, "BENA TSEMAY WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 25, "DEBUB OMO", 587, "JINKA CITY ADMINISTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 25, "DEBUB OMO", 714, "DASENECH"], + [70, "Ethiopia", 9, "SNNPRS", 25, "DEBUB OMO", 715, "DEBUB ARI"], + [70, "Ethiopia", 9, "SNNPRS", 25, "DEBUB OMO", 716, "SEMEN ARI"], + [70, "Ethiopia", 9, "SNNPRS", 25, "DEBUB OMO", 802, "GNANGATOM"], + [70, "Ethiopia", 9, "SNNPRS", 25, "DEBUB OMO", 803, "KURAZ"], + [70, "Ethiopia", 9, "SNNPRS", 25, "DEBUB OMO", 804, "MALE"], + [70, "Ethiopia", 9, "SNNPRS", 25, "DEBUB OMO", 805, "NORTH ARI"], + [70, "Ethiopia", 9, "SNNPRS", 25, "DEBUB OMO", 806, "SOUTH ARI"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 328, "SANKURA"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 329, "AZERNET BERBERE"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 330, "ALICHO WERIRO"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 331, "DALOCHA"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 332, "SILTE"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 333, "LANFRO"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 739, "MIRAB AZERNET BERBERE"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 740, "HULBAREG"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 741, "WERABE CITY ADMINISTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 742, "MISRAK AZERNET BERBERE"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 874, "EAST AZERNET BERBERE"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 875, "WELBAREG"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 876, "WERABE TOWN ADM"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 877, "WEST AZERNET BERBERE"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 1227, "KIBET TOWN ADMINSTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 1228, "TORA TOWN ADMINSTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 1229, "MITO WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 1230, "MISRAK SILTI WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 68, "SILTE", 1275, "TEST"], + [70, "Ethiopia", 9, "SNNPRS", 69, "SEGEN AREA PEOPLE ZONE", 334, "AMARO"], + [70, "Ethiopia", 9, "SNNPRS", 69, "SEGEN AREA PEOPLE ZONE", 335, "BURJI"], + [70, "Ethiopia", 9, "SNNPRS", 69, "SEGEN AREA PEOPLE ZONE", 336, "DERASHE"], + [70, "Ethiopia", 9, "SNNPRS", 69, "SEGEN AREA PEOPLE ZONE", 337, "KONSO"], + [70, "Ethiopia", 9, "SNNPRS", 69, "SEGEN AREA PEOPLE ZONE", 341, "ALABA"], + [70, "Ethiopia", 9, "SNNPRS", 69, "SEGEN AREA PEOPLE ZONE", 990, "ALE"], + [70, "Ethiopia", 9, "SNNPRS", 69, "SEGEN AREA PEOPLE ZONE", 1040, "SEGEN TOWN"], + [70, "Ethiopia", 9, "SNNPRS", 91, "SPECIAL WOREDA", 338, "BASKETO"], + [70, "Ethiopia", 9, "SNNPRS", 91, "SPECIAL WOREDA", 339, "KONTA"], + [70, "Ethiopia", 9, "SNNPRS", 91, "SPECIAL WOREDA", 340, "YEM"], + [70, "Ethiopia", 9, "SNNPRS", 91, "SPECIAL WOREDA", 878, "ALABA CITY ADM"], + [70, "Ethiopia", 9, "SNNPRS", 101, "GOFA ZONE", 278, "UBA DEBERSEHAY WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 101, "GOFA ZONE", 279, "MELO KOZA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 101, "GOFA ZONE", 282, "ZALA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 101, "GOFA ZONE", 582, "SAWLA CITY ADMINISTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 101, "GOFA ZONE", 717, "OYDA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 101, "GOFA ZONE", 718, "GEZE GOFA"], + [70, "Ethiopia", 9, "SNNPRS", 101, "GOFA ZONE", 799, "DEMBA GOFA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 101, "GOFA ZONE", 1231, "MELO GADA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 101, "GOFA ZONE", 1279, "LEHA TOWN ADMINSTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 103, "KONSO ZONE", 1236, "KARAT TOWN ADMINSTRATION"], + [70, "Ethiopia", 9, "SNNPRS", 103, "KONSO ZONE", 1237, "KENNA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 103, "KONSO ZONE", 1238, "SEGEN ZURIYA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 103, "KONSO ZONE", 1239, "KARAT ZURIA WOREDA"], + [70, "Ethiopia", 9, "SNNPRS", 109, "SNNPRS", 1286, "AMAYA CITY ADMINSTRATION "], + [70, "Ethiopia", 9, "SNNPRS", 109, "SNNPRS", 1287, "CHIDA CITY ADMINSTRATION "], + [70, "Ethiopia", 10, "DIRE DAWA", 61, "NO ZONE DIRE DAWA", 20, "WOREDA 1"], + [70, "Ethiopia", 10, "DIRE DAWA", 61, "NO ZONE DIRE DAWA", 374, "WOREDA 2"], + [70, "Ethiopia", 10, "DIRE DAWA", 61, "NO ZONE DIRE DAWA", 375, "WOREDA 3"], + [70, "Ethiopia", 10, "DIRE DAWA", 61, "NO ZONE DIRE DAWA", 376, "WOREDA 4"], + [70, "Ethiopia", 10, "DIRE DAWA", 61, "NO ZONE DIRE DAWA", 631, "NO WOREDA-1100"], + [70, "Ethiopia", 11, "AMAHARA", 45, "BAHIDAR SPECIAL", 14, "NO WOREDA-4"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 62, "DESSIE KETEMA"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 63, "DESSIE ZURIA"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 64, "ALBIKO"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 65, "KOMBOLCHA"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 66, "KALU"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 67, "KUTABER"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 68, "TEHULEDERIA"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 69, "AMBASEL"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 70, "WOREBABO"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 71, "JAMMA"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 72, "WORIELUE"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 73, "LEGAMBO"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 74, "TENTA"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 75, "MEKDELA"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 76, "WOGEDIE"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 78, "KELALA"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 79, "SAINT"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 822, "ARGOBA"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 823, "BORENA"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 824, "LEGEHADI"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 825, "MEHAL SAYINT"], + [70, "Ethiopia", 11, "AMAHARA", 47, "SOUTH WOLLO", 1021, "HAIK TOWN ADMINSTRATION"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 84, "GONDER KETMA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 85, "GONDER ZURIA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 86, "DEMBIA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 87, "ALFA TAKUSA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 88, "METEMA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 89, "KOLA BELESA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 90, "DEGA BELESA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 91, "CHILGA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 92, "LAI-ARMACHIHO"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 93, "TEGDE"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 94, "ARMACHIHO"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 95, "WEGERA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 96, "DABAT"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 97, "DEBARK"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 98, "QUARA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 99, "BEYEDA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 100, "JANAMORA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 101, "ADIARKAYI"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 826, "ALEFA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 827, "DEBARK VICINITY"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 828, "GENDA WEHA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 829, "TACH ARMACHIHO"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 830, "TAKUSA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 831, "TELEMET"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 832, "WEST ARMACHIHO"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 905, "EAST BELESA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 906, "WEST BELESA"], + [70, "Ethiopia", 11, "AMAHARA", 48, "NORTH GONDER", 1019, "METEMA YOHANNES TOWN ADMINISTRATION"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 132, "GOZAMIN"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 133, "D-MARKKOS KETEMA"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 134, "MACHAKEL"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 135, "DEBRE ELIAS"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 136, "BIBUGN"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 137, "AWABEL"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 138, "BASOLIBEN"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 139, "DEJEN"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 140, "EINEMAY"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 141, "DEBAY TILATGEN"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 142, "EINARJ ENAWGA"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 143, "GONCHA-SISO ENESE"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 144, "HULT EIJU EINESIA"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 145, "EINEBSIE SAR MIDER"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 146, "SHEBEL BERENTA"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 833, "ANEDED"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 834, "MOTTA"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 835, "SENAN"], + [70, "Ethiopia", 11, "AMAHARA", 49, "EAST GOJAM", 1029, "DEJEN TOWN ADMINISTRATION"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 77, "DEBRESINA"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 112, "D-BIRHAN KETEMA"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 113, "BASONA WERANA"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 114, "ANGOLALA"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 115, "ASAGRT"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 116, "ANKOBER"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 117, "AGERMARIAM-KETEM"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 118, "BEREHEET"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 119, "EFRATANA GIDME"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 120, "ANTSOKIYANA GEMZ"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 121, "GAIRA-KEYA"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 122, "GESHIE"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 123, "MERAHABETIE"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 124, "KEWET"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 125, "LALO MAMA MEDIR"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 126, "MORETINA -JIRU"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 127, "MINJARINA-SHENKORA"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 128, "MIDANA WOREMO"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 129, "ENSARONA-WAYU"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 130, "TARMA-BER"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 131, "MOJANA WEDERA"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 836, "MENZ GIERA MEDER"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 837, "MENZ LALU MEDER"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 838, "MENZ MAMA MEDER"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 839, "SHOWA ROBIT"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 840, "SIYADBERENA WAYU"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 907, "MENZ KEYA GEBRIEL"], + [70, "Ethiopia", 11, "AMAHARA", 50, "NORTH SHOA", 1016, "MEHALMEDA TOWN ADMINSTRATION"], + [70, "Ethiopia", 11, "AMAHARA", 51, "NORTH WOLLO", 50, "WOLDIA KETEMA"], + [70, "Ethiopia", 11, "AMAHARA", 51, "NORTH WOLLO", 51, "GUBALAFTO"], + [70, "Ethiopia", 11, "AMAHARA", 51, "NORTH WOLLO", 52, "DELANTA DAWINT"], + [70, "Ethiopia", 11, "AMAHARA", 51, "NORTH WOLLO", 53, "KOBO"], + [70, "Ethiopia", 11, "AMAHARA", 51, "NORTH WOLLO", 54, "HABRU"], + [70, "Ethiopia", 11, "AMAHARA", 51, "NORTH WOLLO", 55, "BUGINA"], + [70, "Ethiopia", 11, "AMAHARA", 51, "NORTH WOLLO", 56, "GIDAN"], + [70, "Ethiopia", 11, "AMAHARA", 51, "NORTH WOLLO", 57, "MEKIET"], + [70, "Ethiopia", 11, "AMAHARA", 51, "NORTH WOLLO", 58, "WADELA"], + [70, "Ethiopia", 11, "AMAHARA", 51, "NORTH WOLLO", 841, "DAWUNT"], + [70, "Ethiopia", 11, "AMAHARA", 51, "NORTH WOLLO", 842, "DELANTA"], + [70, "Ethiopia", 11, "AMAHARA", 51, "NORTH WOLLO", 843, "KOBO VICINITY"], + [70, "Ethiopia", 11, "AMAHARA", 51, "NORTH WOLLO", 844, "LALIBELA"], + [70, "Ethiopia", 11, "AMAHARA", 51, "NORTH WOLLO", 845, "LASTA"], + [70, "Ethiopia", 11, "AMAHARA", 51, "NORTH WOLLO", 1020, "MERSA TOWN ADMINISTRATION"], + [70, "Ethiopia", 11, "AMAHARA", 52, "SOUTH GONDER", 102, "DEBRETABOR KETEMA"], + [70, "Ethiopia", 11, "AMAHARA", 52, "SOUTH GONDER", 103, "FARTA"], + [70, "Ethiopia", 11, "AMAHARA", 52, "SOUTH GONDER", 104, "ESTIE"], + [70, "Ethiopia", 11, "AMAHARA", 52, "SOUTH GONDER", 105, "DERRA"], + [70, "Ethiopia", 11, "AMAHARA", 52, "SOUTH GONDER", 106, "LAI-GAINT"], + [70, "Ethiopia", 11, "AMAHARA", 52, "SOUTH GONDER", 107, "TACH-GAINT"], + [70, "Ethiopia", 11, "AMAHARA", 52, "SOUTH GONDER", 108, "SIMADA"], + [70, "Ethiopia", 11, "AMAHARA", 52, "SOUTH GONDER", 109, "FOGERA"], + [70, "Ethiopia", 11, "AMAHARA", 52, "SOUTH GONDER", 110, "LIBOKEMEKEM"], + [70, "Ethiopia", 11, "AMAHARA", 52, "SOUTH GONDER", 111, "EBINAT"], + [70, "Ethiopia", 11, "AMAHARA", 52, "SOUTH GONDER", 846, "EAST ESTIE"], + [70, "Ethiopia", 11, "AMAHARA", 52, "SOUTH GONDER", 847, "WEST ESTIE"], + [70, "Ethiopia", 11, "AMAHARA", 52, "SOUTH GONDER", 848, "WORETA"], + [70, "Ethiopia", 11, "AMAHARA", 52, "SOUTH GONDER", 1023, "ADIS ZEMEN TOWN ADMINISTRATION"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 147, "BAHIRDAR ZURIA"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 148, "YILMANA-DENSA"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 149, "MECHA"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 150, "ACHEFER"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 151, "SEKELA"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 152, "BURIE"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 153, "WONBERMA"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 154, "JABI-TIHNAN"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 155, "QUARIT"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 156, "DENBECHA"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 157, "DEGADAMOT"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 849, "BAHIRDAR TOWN ADMINIS."], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 850, "BURIE VICINITY"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 851, "FENOTE SELAM"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 852, "GONJI KOLELA"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 853, "NORTH ACHEFER"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 854, "SOUTH ACHEFER"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 1025, "DEMBECHA TOWN ADMINSTRATION"], + [70, "Ethiopia", 11, "AMAHARA", 54, "OROMIA", 80, "JILIENA TIMUGA"], + [70, "Ethiopia", 11, "AMAHARA", 54, "OROMIA", 81, "ARTUMA FARSI"], + [70, "Ethiopia", 11, "AMAHARA", 54, "OROMIA", 82, "DAWA-CHEFA"], + [70, "Ethiopia", 11, "AMAHARA", 54, "OROMIA", 83, "BATI"], + [70, "Ethiopia", 11, "AMAHARA", 54, "OROMIA", 855, "DEWIHAREWA"], + [70, "Ethiopia", 11, "AMAHARA", 54, "OROMIA", 856, "KEMISIE TOWN ADMINISTRATION"], + [70, "Ethiopia", 11, "AMAHARA", 54, "OROMIA", 1031, "BATI TOWN ADMINISTRATION"], + [70, "Ethiopia", 11, "AMAHARA", 55, "WAGHIMRA", 59, "SEKOTA"], + [70, "Ethiopia", 11, "AMAHARA", 55, "WAGHIMRA", 60, "DEHANA"], + [70, "Ethiopia", 11, "AMAHARA", 55, "WAGHIMRA", 61, "ZIQUALA"], + [70, "Ethiopia", 11, "AMAHARA", 55, "WAGHIMRA", 857, "ABERGELIE"], + [70, "Ethiopia", 11, "AMAHARA", 55, "WAGHIMRA", 858, "GAZIBELA"], + [70, "Ethiopia", 11, "AMAHARA", 55, "WAGHIMRA", 859, "SEHALA"], + [70, "Ethiopia", 11, "AMAHARA", 55, "WAGHIMRA", 860, "SEKOTA VICINITY"], + [70, "Ethiopia", 11, "AMAHARA", 66, "AWI", 158, "BANJA-SHIKUDAD"], + [70, "Ethiopia", 11, "AMAHARA", 66, "AWI", 159, "GUANGUA"], + [70, "Ethiopia", 11, "AMAHARA", 66, "AWI", 160, "DANGILA"], + [70, "Ethiopia", 11, "AMAHARA", 66, "AWI", 161, "ANKESHA-GUAGUSA"], + [70, "Ethiopia", 11, "AMAHARA", 66, "AWI", 162, "FAGTA LEKOMA"], + [70, "Ethiopia", 11, "AMAHARA", 66, "AWI", 867, "ANKESHA "], + [70, "Ethiopia", 11, "AMAHARA", 66, "AWI", 868, "CHAGNI"], + [70, "Ethiopia", 11, "AMAHARA", 66, "AWI", 869, "DANGELA VICINITY"], + [70, "Ethiopia", 11, "AMAHARA", 66, "AWI", 870, "ENJEBARA TOWN ADMIN"], + [70, "Ethiopia", 11, "AMAHARA", 66, "AWI", 871, "GUAGUSA"], + [70, "Ethiopia", 11, "AMAHARA", 66, "AWI", 872, "JAWI"], + [70, "Ethiopia", 11, "AMAHARA", 66, "AWI", 1032, "ZIGEM"], + [70, "Ethiopia", 12, "NO REGION", 76, "NO ZONE REGION", 653, "NO WOREDA REGION"], + [70, "Ethiopia", 13, "ADDIS ABABA", 14, "ARADA", 568, "NO WOREDA-139"], + [70, "Ethiopia", 13, "ADDIS ABABA", 14, "ARADA", 1056, "WOREDA 1"], + [70, "Ethiopia", 13, "ADDIS ABABA", 14, "ARADA", 1057, "WOREDA 2"], + [70, "Ethiopia", 13, "ADDIS ABABA", 14, "ARADA", 1058, "WOREDA 3"], + [70, "Ethiopia", 13, "ADDIS ABABA", 14, "ARADA", 1059, "WOREDA 4"], + [70, "Ethiopia", 13, "ADDIS ABABA", 14, "ARADA", 1060, "WOREDA 5"], + [70, "Ethiopia", 13, "ADDIS ABABA", 14, "ARADA", 1061, "WOREDA 6"], + [70, "Ethiopia", 13, "ADDIS ABABA", 14, "ARADA", 1062, "WOREDA 7"], + [70, "Ethiopia", 13, "ADDIS ABABA", 14, "ARADA", 1063, "WOREDA 9"], + [70, "Ethiopia", 13, "ADDIS ABABA", 14, "ARADA", 1064, "WOREDA 8"], + [70, "Ethiopia", 13, "ADDIS ABABA", 14, "ARADA", 1065, "WOREDA 10"], + [70, "Ethiopia", 13, "ADDIS ABABA", 15, "ADDIS KETEMA", 569, "NO WOREDA-140"], + [70, "Ethiopia", 13, "ADDIS ABABA", 15, "ADDIS KETEMA", 1042, "WOREDA 2"], + [70, "Ethiopia", 13, "ADDIS ABABA", 15, "ADDIS KETEMA", 1048, "WOREDA 3"], + [70, "Ethiopia", 13, "ADDIS ABABA", 15, "ADDIS KETEMA", 1049, "WOREDA 4"], + [70, "Ethiopia", 13, "ADDIS ABABA", 15, "ADDIS KETEMA", 1050, "WOREDA 5"], + [70, "Ethiopia", 13, "ADDIS ABABA", 15, "ADDIS KETEMA", 1051, "WOREDA 6"], + [70, "Ethiopia", 13, "ADDIS ABABA", 15, "ADDIS KETEMA", 1052, "WOREDA 7"], + [70, "Ethiopia", 13, "ADDIS ABABA", 15, "ADDIS KETEMA", 1053, "WOREDA 9"], + [70, "Ethiopia", 13, "ADDIS ABABA", 15, "ADDIS KETEMA", 1054, "WOREDA 10"], + [70, "Ethiopia", 13, "ADDIS ABABA", 15, "ADDIS KETEMA", 1055, "WOREDA 8"], + [70, "Ethiopia", 13, "ADDIS ABABA", 29, "LIDETA", 571, "NO WOREDA-141"], + [70, "Ethiopia", 13, "ADDIS ABABA", 29, "LIDETA", 1066, "WOREDA 1"], + [70, "Ethiopia", 13, "ADDIS ABABA", 29, "LIDETA", 1067, "WOREDA 2"], + [70, "Ethiopia", 13, "ADDIS ABABA", 29, "LIDETA", 1068, "WOREDA 3"], + [70, "Ethiopia", 13, "ADDIS ABABA", 29, "LIDETA", 1069, "WOREDA 4"], + [70, "Ethiopia", 13, "ADDIS ABABA", 29, "LIDETA", 1070, "WOREDA 5"], + [70, "Ethiopia", 13, "ADDIS ABABA", 29, "LIDETA", 1071, "WOREDA 6"], + [70, "Ethiopia", 13, "ADDIS ABABA", 29, "LIDETA", 1072, "WOREDA 7"], + [70, "Ethiopia", 13, "ADDIS ABABA", 29, "LIDETA", 1073, "WOREDA 9"], + [70, "Ethiopia", 13, "ADDIS ABABA", 29, "LIDETA", 1074, "WOREDA 8"], + [70, "Ethiopia", 13, "ADDIS ABABA", 29, "LIDETA", 1075, "WOREDA 10"], + [70, "Ethiopia", 13, "ADDIS ABABA", 63, "KIRKOS", 572, "NO WOREDA-142"], + [70, "Ethiopia", 13, "ADDIS ABABA", 63, "KIRKOS", 1076, "WOREDA 1"], + [70, "Ethiopia", 13, "ADDIS ABABA", 63, "KIRKOS", 1077, "WOREDA 2"], + [70, "Ethiopia", 13, "ADDIS ABABA", 63, "KIRKOS", 1078, "WOREDA 3"], + [70, "Ethiopia", 13, "ADDIS ABABA", 63, "KIRKOS", 1079, "WOREDA 4"], + [70, "Ethiopia", 13, "ADDIS ABABA", 63, "KIRKOS", 1080, "WOREDA 5"], + [70, "Ethiopia", 13, "ADDIS ABABA", 63, "KIRKOS", 1081, "WOREDA 6"], + [70, "Ethiopia", 13, "ADDIS ABABA", 63, "KIRKOS", 1082, "WOREDA 7"], + [70, "Ethiopia", 13, "ADDIS ABABA", 63, "KIRKOS", 1083, "WOREDA 9"], + [70, "Ethiopia", 13, "ADDIS ABABA", 63, "KIRKOS", 1084, "WOREDA 8"], + [70, "Ethiopia", 13, "ADDIS ABABA", 63, "KIRKOS", 1085, "WOREDA 10"], + [70, "Ethiopia", 13, "ADDIS ABABA", 63, "KIRKOS", 1086, "WOREDA 11"], + [70, "Ethiopia", 13, "ADDIS ABABA", 77, "YEKA", 573, "NO WOREDA-143"], + [70, "Ethiopia", 13, "ADDIS ABABA", 77, "YEKA", 1087, "WOREDA 1"], + [70, "Ethiopia", 13, "ADDIS ABABA", 77, "YEKA", 1088, "WOREDA 2"], + [70, "Ethiopia", 13, "ADDIS ABABA", 77, "YEKA", 1089, "WOREDA 3"], + [70, "Ethiopia", 13, "ADDIS ABABA", 77, "YEKA", 1090, "WOREDA 4"], + [70, "Ethiopia", 13, "ADDIS ABABA", 77, "YEKA", 1091, "WOREDA 5"], + [70, "Ethiopia", 13, "ADDIS ABABA", 77, "YEKA", 1092, "WOREDA 6"], + [70, "Ethiopia", 13, "ADDIS ABABA", 77, "YEKA", 1093, "WOREDA 7"], + [70, "Ethiopia", 13, "ADDIS ABABA", 77, "YEKA", 1094, "WOREDA 9"], + [70, "Ethiopia", 13, "ADDIS ABABA", 77, "YEKA", 1095, "WOREDA 8"], + [70, "Ethiopia", 13, "ADDIS ABABA", 77, "YEKA", 1096, "WOREDA 10"], + [70, "Ethiopia", 13, "ADDIS ABABA", 77, "YEKA", 1097, "WOREDA 11"], + [70, "Ethiopia", 13, "ADDIS ABABA", 77, "YEKA", 1098, "WOREDA 12"], + [70, "Ethiopia", 13, "ADDIS ABABA", 77, "YEKA", 1099, "WOREDA 13"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 574, "NO WOREDA-144"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1100, "WOREDA 1"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1101, "WOREDA 2"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1102, "WOREDA 3"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1103, "WOREDA 4"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1104, "WOREDA 5"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1105, "WOREDA 6"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1106, "WOREDA 7"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1107, "WOREDA 9"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1108, "WOREDA 8"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1109, "WOREDA 10"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1110, "WOREDA 11"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1111, "WOREDA 12"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1112, "WOREDA 13"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1113, "WOREDA 14"], + [70, "Ethiopia", 13, "ADDIS ABABA", 79, "AKAKI KALITI", 575, "NO WOREDA-145"], + [70, "Ethiopia", 13, "ADDIS ABABA", 79, "AKAKI KALITI", 1114, "WOREDA 1"], + [70, "Ethiopia", 13, "ADDIS ABABA", 79, "AKAKI KALITI", 1115, "WOREDA 2"], + [70, "Ethiopia", 13, "ADDIS ABABA", 79, "AKAKI KALITI", 1116, "WOREDA 3"], + [70, "Ethiopia", 13, "ADDIS ABABA", 79, "AKAKI KALITI", 1117, "WOREDA 4"], + [70, "Ethiopia", 13, "ADDIS ABABA", 79, "AKAKI KALITI", 1118, "WOREDA 5"], + [70, "Ethiopia", 13, "ADDIS ABABA", 79, "AKAKI KALITI", 1119, "WOREDA 6"], + [70, "Ethiopia", 13, "ADDIS ABABA", 79, "AKAKI KALITI", 1120, "WOREDA 7"], + [70, "Ethiopia", 13, "ADDIS ABABA", 79, "AKAKI KALITI", 1121, "WOREDA 8"], + [70, "Ethiopia", 13, "ADDIS ABABA", 80, "NEFAS SILK LAFTO", 576, "NO WOREDA-146"], + [70, "Ethiopia", 13, "ADDIS ABABA", 80, "NEFAS SILK LAFTO", 1046, "WOREDA 1"], + [70, "Ethiopia", 13, "ADDIS ABABA", 80, "NEFAS SILK LAFTO", 1122, "WOREDA 2"], + [70, "Ethiopia", 13, "ADDIS ABABA", 80, "NEFAS SILK LAFTO", 1123, "WOREDA 3"], + [70, "Ethiopia", 13, "ADDIS ABABA", 80, "NEFAS SILK LAFTO", 1124, "WOREDA 4"], + [70, "Ethiopia", 13, "ADDIS ABABA", 80, "NEFAS SILK LAFTO", 1125, "WOREDA 5"], + [70, "Ethiopia", 13, "ADDIS ABABA", 80, "NEFAS SILK LAFTO", 1126, "WOREDA 6"], + [70, "Ethiopia", 13, "ADDIS ABABA", 80, "NEFAS SILK LAFTO", 1127, "WOREDA 7"], + [70, "Ethiopia", 13, "ADDIS ABABA", 80, "NEFAS SILK LAFTO", 1128, "WOREDA 9"], + [70, "Ethiopia", 13, "ADDIS ABABA", 80, "NEFAS SILK LAFTO", 1129, "WOREDA 8"], + [70, "Ethiopia", 13, "ADDIS ABABA", 80, "NEFAS SILK LAFTO", 1130, "WOREDA 10"], + [70, "Ethiopia", 13, "ADDIS ABABA", 80, "NEFAS SILK LAFTO", 1131, "WOREDA 11"], + [70, "Ethiopia", 13, "ADDIS ABABA", 80, "NEFAS SILK LAFTO", 1132, "WOREDA 12"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 577, "NO WOREDA-147"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1133, "WOREDA 1"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1134, "WOREDA 2"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1135, "WOREDA 3"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1136, "WOREDA 4"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1137, "WOREDA 5"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1138, "WOREDA 6"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1139, "WOREDA 7"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1140, "WOREDA 9"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1141, "WOREDA 8"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1142, "WOREDA 10"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1143, "WOREDA 11"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1144, "WOREDA 12"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1145, "WOREDA 13"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1146, "WOREDA 14"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1147, "WOREDA 15"], + [70, "Ethiopia", 13, "ADDIS ABABA", 82, "GULLELE", 578, "NO WOREDA-148"], + [70, "Ethiopia", 13, "ADDIS ABABA", 82, "GULLELE", 1148, "WOREDA 1"], + [70, "Ethiopia", 13, "ADDIS ABABA", 82, "GULLELE", 1149, "WOREDA 2"], + [70, "Ethiopia", 13, "ADDIS ABABA", 82, "GULLELE", 1150, "WOREDA 3"], + [70, "Ethiopia", 13, "ADDIS ABABA", 82, "GULLELE", 1151, "WOREDA 4"], + [70, "Ethiopia", 13, "ADDIS ABABA", 82, "GULLELE", 1152, "WOREDA 5"], + [70, "Ethiopia", 13, "ADDIS ABABA", 82, "GULLELE", 1153, "WOREDA 6"], + [70, "Ethiopia", 13, "ADDIS ABABA", 82, "GULLELE", 1154, "WOREDA 7"], + [70, "Ethiopia", 13, "ADDIS ABABA", 82, "GULLELE", 1155, "WOREDA 9"], + [70, "Ethiopia", 13, "ADDIS ABABA", 82, "GULLELE", 1156, "WOREDA 8"], + [70, "Ethiopia", 13, "ADDIS ABABA", 82, "GULLELE", 1157, "WOREDA 10"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 11, "AWASSA VICINITY"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 239, "SHEBEDINO"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 240, "DALE"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 241, "ALETA WONDO"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 242, "AGERESELAM"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 243, "BENSSA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 244, "ARBEGONA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 245, "ARORESSA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 246, "DARRA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 563, "HAWASSA CITY ADMINISTRATION"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 564, "ALETAWONDO TOWN ADMIN"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 565, "YIRGALEM TOWN ADMIN"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 730, "WENDO GENET"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 731, "MELGA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 732, "GORCHE"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 733, "WENSHO"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 734, "LOKA ABAYA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 735, "CHUKO"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 736, "BONA ZURIA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 737, "BURSA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 738, "CHIRE"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 786, "ALETA CHUKO"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 787, "BUNA VICINITY"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 788, "GORECHIE"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 789, "WENISHO"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 1039, "LEKU TOWN"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 1258, "WONDO GENET TOWN ADMINSTRATION"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 1259, "ALETA CHUKO TOWN ADMINSTRATION"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 1260, "HOKO WOREDA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 1261, "HAWELA WOREDA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 1262, "DA'ELA WOREDA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 1263, "DARARA WOREDA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 1264, "TETICHA WOREDA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 1265, "CHIRONE WOREDA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 1266, "BILATE ZURIA WOREDA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 1267, "CHEBE WOREDA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 1268, "BURA WOREDA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 1269, "SHAFAMO WOREDA"], + [70, "Ethiopia", 14, "SIDAMA", 17, "SIDAMA", 1270, "DARA OTILICHO WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 24, "DAWRO", 295, "MAREQA"], + [70, "Ethiopia", 15, "SOUTH WEST", 24, "DAWRO", 296, "LOMA"], + [70, "Ethiopia", 15, "SOUTH WEST", 24, "DAWRO", 297, "TOCHA"], + [70, "Ethiopia", 15, "SOUTH WEST", 24, "DAWRO", 298, "ZABA GAZO WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 24, "DAWRO", 299, "ESERA"], + [70, "Ethiopia", 15, "SOUTH WEST", 24, "DAWRO", 713, "TERCHA CITY ADMINISTRATION"], + [70, "Ethiopia", 15, "SOUTH WEST", 24, "DAWRO", 1205, "GENA WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 24, "DAWRO", 1211, "DISA WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 24, "DAWRO", 1212, "TARCHA ZURIA WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 24, "DAWRO", 1213, "MARI MANSA WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 24, "DAWRO", 1214, "KECHI WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 306, "GIMBO"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 307, "DECHA"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 308, "CHENA"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 309, "BITA"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 310, "TELLO"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 311, "MENGEO"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 312, "GESHA"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 313, "CHETA"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 314, "SAYILEM"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 315, "GEWATA"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 588, "BONGA CITY ADMINISTRATION"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 807, "ADIYUO"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 1255, "WACHA TOWN ADMINSTRATION"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 1256, "GOBA WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 1257, "SHISHO ENDO WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 1283, "SHISHO ENDO CITY ADMINSTRATION "], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 1284, "DAKA CITY ADMINSTRATION "], + [70, "Ethiopia", 15, "SOUTH WEST", 26, "KEFFA", 1285, "AWRADA CITY ADMINSTRATION "], + [70, "Ethiopia", 15, "SOUTH WEST", 46, "BENCH SHEKO ZONE", 316, "BENCH"], + [70, "Ethiopia", 15, "SOUTH WEST", 46, "BENCH SHEKO ZONE", 317, "SHEKO WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 46, "BENCH SHEKO ZONE", 319, "GURA FERDA WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 46, "BENCH SHEKO ZONE", 321, "SHEWA BENCH"], + [70, "Ethiopia", 15, "SOUTH WEST", 46, "BENCH SHEKO ZONE", 589, "MIZANTEFERI CITY ADMINISTRATION"], + [70, "Ethiopia", 15, "SOUTH WEST", 46, "BENCH SHEKO ZONE", 711, "SEMEN BENCH WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 46, "BENCH SHEKO ZONE", 712, "DEBUB BENCH WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 46, "BENCH SHEKO ZONE", 820, "NORTH BENCH"], + [70, "Ethiopia", 15, "SOUTH WEST", 46, "BENCH SHEKO ZONE", 821, "SOUTH BENCH"], + [70, "Ethiopia", 15, "SOUTH WEST", 46, "BENCH SHEKO ZONE", 1209, "GIDI BENCH WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 67, "SHEKA", 325, "MASHA"], + [70, "Ethiopia", 15, "SOUTH WEST", 67, "SHEKA", 326, "YEKI"], + [70, "Ethiopia", 15, "SOUTH WEST", 67, "SHEKA", 327, "ANDERACHA"], + [70, "Ethiopia", 15, "SOUTH WEST", 67, "SHEKA", 590, "TEPPI CITY ADMINISTRATION"], + [70, "Ethiopia", 15, "SOUTH WEST", 67, "SHEKA", 729, "MASHA CITY ADMINISTRATION"], + [70, "Ethiopia", 15, "SOUTH WEST", 67, "SHEKA", 873, "MASHA TOWN ADM"], + [70, "Ethiopia", 15, "SOUTH WEST", 102, "MIRAB OMO ZONE", 318, "MENIT GOLDIA WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 102, "MIRAB OMO ZONE", 320, "SURMA WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 102, "MIRAB OMO ZONE", 322, "MENIT SHASHA WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 102, "MIRAB OMO ZONE", 323, "BERO WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 102, "MIRAB OMO ZONE", 324, "MAJI WOREDA"], + [70, "Ethiopia", 15, "SOUTH WEST", 102, "MIRAB OMO ZONE", 1280, "BACHUMA CITY ADMINSTRATION "], + [70, "Ethiopia", 15, "SOUTH WEST", 102, "MIRAB OMO ZONE", 1281, "JEMU CITY ADMINSTRATION "], + [70, "Ethiopia", 15, "SOUTH WEST", 102, "MIRAB OMO ZONE", 1282, "MAJI TUM CITY ADMINSTRATION "], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 283, "GORO"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 284, "CHEHA"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 285, "ENEMOR"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 286, "EZA"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 287, "GUMER"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 288, "KOKIR"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 289, "MESKAN"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 290, "ABASHEGE"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 291, "MAREKO"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 292, "SODO"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 293, "MIHUR AKLIL"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 294, "ENDEGAN"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 583, "WOLKITE CITY ADMINISTRATION"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 584, "BUTAJERA CITY ADMINISTRATION"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 625, "KEBENA"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 723, "GETA"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 801, "GETO"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 1215, "ENDIBIR TOWN ADMINSTRATION"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 1216, "BUIE TOWN ADMINSTRATION"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 1217, "DEBUB SODO WOREDA"], + [70, "Ethiopia", 17, "CENTERAL ETHIOPIA", 23, "GURAGE", 1272, "ENSENO TOWN ADMINSTRATION"], +]; diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index 36e4e34d0..6a64f6199 100644 --- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -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 ? { diff --git a/apps/edr-freight-api/src/migrations/3690000000000-AuditLogReference.ts b/apps/edr-freight-api/src/migrations/3690000000000-AuditLogReference.ts new file mode 100644 index 000000000..499944a05 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3690000000000-AuditLogReference.ts @@ -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 { + 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 { + // 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`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3690000000000-CheckpointHandlingTimes.ts b/apps/edr-freight-api/src/migrations/3690000000000-CheckpointHandlingTimes.ts new file mode 100644 index 000000000..f4cae1161 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3690000000000-CheckpointHandlingTimes.ts @@ -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 { + 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 { + 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; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3700000000000-HandlingStandards.ts b/apps/edr-freight-api/src/migrations/3700000000000-HandlingStandards.ts new file mode 100644 index 000000000..94a9531a5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3700000000000-HandlingStandards.ts @@ -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 { + 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 { + await queryRunner.query(` + ALTER TABLE freight.operations_standards + DROP COLUMN IF EXISTS handling_standard_hours_container, + DROP COLUMN IF EXISTS handling_standard_hours_bulk; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3710000000000-BookingClearingAgentContact.ts b/apps/edr-freight-api/src/migrations/3710000000000-BookingClearingAgentContact.ts new file mode 100644 index 000000000..0590d6395 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3710000000000-BookingClearingAgentContact.ts @@ -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 { + 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 { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS customs_clearing_agent_email, + DROP COLUMN IF EXISTS customs_clearing_agent_phone + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3720000000000-ScheduleStationWorkLogs.ts b/apps/edr-freight-api/src/migrations/3720000000000-ScheduleStationWorkLogs.ts new file mode 100644 index 000000000..6998895c2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3720000000000-ScheduleStationWorkLogs.ts @@ -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 { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS station_work_logs jsonb + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS station_work_logs + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3730000000000-WagonDetachRequests.ts b/apps/edr-freight-api/src/migrations/3730000000000-WagonDetachRequests.ts new file mode 100644 index 000000000..1c7fcb3db --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3730000000000-WagonDetachRequests.ts @@ -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 { + 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 { + await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_detach_requests`); + await queryRunner.query(`DROP TYPE IF EXISTS freight.wagon_detach_requests_status_enum`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3740000000000-BookingBulkRequestedWagons.ts b/apps/edr-freight-api/src/migrations/3740000000000-BookingBulkRequestedWagons.ts new file mode 100644 index 000000000..64c9ac2c7 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3740000000000-BookingBulkRequestedWagons.ts @@ -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 { + 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 { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS bulk_requested_wagons, + DROP COLUMN IF EXISTS bulk_item_count + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3750000000000-EthiopianCustomsContractTemplates.ts b/apps/edr-freight-api/src/migrations/3750000000000-EthiopianCustomsContractTemplates.ts new file mode 100644 index 000000000..564972333 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3750000000000-EthiopianCustomsContractTemplates.ts @@ -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 { + 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 { + 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 + `); + } +} diff --git a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts index 019d99920..50093ea67 100644 --- a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts +++ b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts @@ -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> = { "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> = { "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> = { "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> = { "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> = { "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> = { "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> = { "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> = { "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> = { // 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> = { // 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> = { "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> = { "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> = { "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> = { "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"], diff --git a/apps/edr-freight-api/src/modules/audit/audit-log.repository.ts b/apps/edr-freight-api/src/modules/audit/audit-log.repository.ts index 91d6c9901..dcbe53306 100644 --- a/apps/edr-freight-api/src/modules/audit/audit-log.repository.ts +++ b/apps/edr-freight-api/src/modules/audit/audit-log.repository.ts @@ -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 { ); } + /** + * 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 { + 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 = {}; + 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 { return rows.map((row) => row.type); } + + /** Distinct action titles present, for the action filter dropdown. */ + async distinctTitles(): Promise { + 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}`); } diff --git a/apps/edr-freight-api/src/modules/audit/audit-reference.registry.ts b/apps/edr-freight-api/src/modules/audit/audit-reference.registry.ts new file mode 100644 index 000000000..9cba31abb --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit-reference.registry.ts @@ -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> = { + 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; diff --git a/apps/edr-freight-api/src/modules/audit/audit.controller.ts b/apps/edr-freight-api/src/modules/audit/audit.controller.ts index 1a07a5fe5..2e08b2cd9 100644 --- a/apps/edr-freight-api/src/modules/audit/audit.controller.ts +++ b/apps/edr-freight-api/src/modules/audit/audit.controller.ts @@ -43,4 +43,13 @@ export class AuditController { types(): Promise { 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 { + return this.auditService.listActions(); + } } diff --git a/apps/edr-freight-api/src/modules/audit/audit.service.ts b/apps/edr-freight-api/src/modules/audit/audit.service.ts index de7dfd956..9816d79f5 100644 --- a/apps/edr-freight-api/src/modules/audit/audit.service.ts +++ b/apps/edr-freight-api/src/modules/audit/audit.service.ts @@ -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): Promise { 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 { + 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> { 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 { return this.auditLogRepository.distinctTypes(); } + + /** Distinct action titles, for the action filter dropdown. */ + async listActions(): Promise { + return this.auditLogRepository.distinctTitles(); + } } diff --git a/apps/edr-freight-api/src/modules/audit/dto/audit-log-query.dto.ts b/apps/edr-freight-api/src/modules/audit/dto/audit-log-query.dto.ts index 5a5199538..dbd41e14c 100644 --- a/apps/edr-freight-api/src/modules/audit/dto/audit-log-query.dto.ts +++ b/apps/edr-freight-api/src/modules/audit/dto/audit-log-query.dto.ts @@ -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.', }) diff --git a/apps/edr-freight-api/src/modules/audit/entities/audit-log.entity.ts b/apps/edr-freight-api/src/modules/audit/entities/audit-log.entity.ts index 0ff7727ff..8261fc61c 100644 --- a/apps/edr-freight-api/src/modules/audit/entities/audit-log.entity.ts +++ b/apps/edr-freight-api/src/modules/audit/entities/audit-log.entity.ts @@ -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 — diff --git a/apps/edr-freight-api/src/modules/auth/account.controller.ts b/apps/edr-freight-api/src/modules/auth/account.controller.ts index d7d7f15c3..96f90e8a3 100644 --- a/apps/edr-freight-api/src/modules/auth/account.controller.ts +++ b/apps/edr-freight-api/src/modules/auth/account.controller.ts @@ -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) {} diff --git a/apps/edr-freight-api/src/modules/auth/freight-me.controller.ts b/apps/edr-freight-api/src/modules/auth/freight-me.controller.ts index b85ecea84..f1c974687 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-me.controller.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-me.controller.ts @@ -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', }) diff --git a/apps/edr-freight-api/src/modules/auth/freight-me.service.ts b/apps/edr-freight-api/src/modules/auth/freight-me.service.ts index 6bcfd964d..b897c5fef 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-me.service.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-me.service.ts @@ -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['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), ]), ]; diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 39c5a36ad..175e3e00c 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -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 { 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]); diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts index aec6e4ac0..a98ad06c1 100644 --- a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts @@ -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 = { @@ -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() diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts index 75d39a500..bcc7fcfc3 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.spec.ts @@ -60,11 +60,7 @@ const context = (over: Partial = {}): 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", () => { diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts index c72e61c7a..0b9eb9e29 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts @@ -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; - /** - * 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; - /** - * 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; - /** - * 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; + 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, - 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, - 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 | 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, diff --git a/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts index ab1e27b1a..172e74c17 100644 --- a/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts +++ b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 49732d2df..29104bbce 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -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 " #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), diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 399f14123..264ffb811 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -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"; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts index 8babc8c2e..07d4c4e06 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts @@ -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; + }; + 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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index c1f4114a7..808421a0c 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -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 { 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 { + 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 { + // 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) { diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 04ebc2d41..c76655240 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -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: diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index ff0888bef..885486031 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -377,6 +377,58 @@ export class BookingsRepository extends BaseRepository { }); } + /** + * 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 { + 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 diff --git a/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts index 0ee77aeed..d5e1a0e69 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts @@ -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 " #2" when unnumbered' }) + containerLabel!: string; + + @ApiProperty({ description: "This container's VGM in tons" }) totalVgmTons!: number; @ApiProperty() diff --git a/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts index 624762fac..631e78109 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts @@ -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 { diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index c85e15cfb..67a486068 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/chat/chat.controller.ts b/apps/edr-freight-api/src/modules/chat/chat.controller.ts index 0ecca04c0..5e122bdd2 100644 --- a/apps/edr-freight-api/src/modules/chat/chat.controller.ts +++ b/apps/edr-freight-api/src/modules/chat/chat.controller.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/contract-templates/bulk-template-direction.spec.ts b/apps/edr-freight-api/src/modules/contract-templates/bulk-template-direction.spec.ts index b822387da..1c1b304c9 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/bulk-template-direction.spec.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/bulk-template-direction.spec.ts @@ -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, ); }); }); diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts index 803814967..50a912a1d 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts @@ -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()); }); diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts index 1df3b1302..58c452f0c 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts @@ -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 { - 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 { return this.repository .createQueryBuilder("t") @@ -60,6 +73,9 @@ export class ContractTemplatesRepository extends BaseRepository = { 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 { 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 { 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; diff --git a/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts b/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts index 9f7579dbd..7676749f3 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts index e070aa1e4..f94e5d52f 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts index bdb8d74cd..565164995 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts @@ -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; + }; + + 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(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index f4236ce7f..f5e00f503 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -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 { + 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(); + 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)), + }; }), ); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index cf253347b..c95eb73b7 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts index b86b08444..a7bd3cd7d 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts @@ -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'), diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 48c90fcbc..936176f86 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -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 { diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index f50ca9d4d..0592dafc4 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/contracts/shipment-preview-parity.spec.ts b/apps/edr-freight-api/src/modules/contracts/shipment-preview-parity.spec.ts index 4152ffb30..d493147b9 100644 --- a/apps/edr-freight-api/src/modules/contracts/shipment-preview-parity.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/shipment-preview-parity.spec.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts b/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts index a6c8c8d6f..58c97619a 100644 --- a/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts @@ -61,6 +61,7 @@ describe('ContractClearanceService — transit assignee', () => { {} as never, notifier as never, transitAgentsService as never, + {} as never, // dataSource ); }); diff --git a/apps/edr-freight-api/src/modules/eims/dto/register-sales-receipt.dto.ts b/apps/edr-freight-api/src/modules/eims/dto/register-sales-receipt.dto.ts index 70681f814..1d3efdf60 100644 --- a/apps/edr-freight-api/src/modules/eims/dto/register-sales-receipt.dto.ts +++ b/apps/edr-freight-api/src/modules/eims/dto/register-sales-receipt.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.spec.ts index 459ee8f71..1cdca48d4 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.spec.ts @@ -36,9 +36,12 @@ const invoiceRow = (over: Partial = {}): 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", diff --git a/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.ts index 4638d3555..875349b6a 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.ts @@ -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 { @@ -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, diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.spec.ts index 251f71cbd..135475b64 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.spec.ts @@ -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", () => { diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts index 4c1c82c10..2fb83dc72 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts index f560ff917..69a4d5ffd 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts @@ -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 => 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: "" } }); diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts index a823e5ddf..d5c987779 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts index 93f50e827..55a38480b 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts @@ -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()); diff --git a/apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts b/apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts index 2bdb6cbe2..f2de2937c 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.spec.ts index 7b4ff1b3f..2c0e50b78 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.spec.ts @@ -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 = {}) => ({ 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(); diff --git a/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts index a8d242929..be3e3aa8e 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts @@ -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( diff --git a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts index a55951db4..f85459e9e 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts @@ -32,11 +32,6 @@ export const eimsInvoiceConfig = (over: Partial = {}): 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: {}, diff --git a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts index 67f2207e9..56ab3b74e 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/exports/exports.controller.ts b/apps/edr-freight-api/src/modules/exports/exports.controller.ts index 89ca7419a..aea35a9bc 100644 --- a/apps/edr-freight-api/src/modules/exports/exports.controller.ts +++ b/apps/edr-freight-api/src/modules/exports/exports.controller.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts index 21e0dd9a0..8cdc83853 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts @@ -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], diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts index ccec40139..be94e3f32 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts @@ -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, @@ -44,6 +50,8 @@ export class ImportOperationsService { private readonly emptyReturns: Repository, 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 { + 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 { const row = await this.emptyReturns.findOne({ where: { id } }); if (!row) { diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts index 6fb82c3a0..16026cc96 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts @@ -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) {} diff --git a/apps/edr-freight-api/src/modules/operations-reporting/dto/update-operations-standards.dto.ts b/apps/edr-freight-api/src/modules/operations-reporting/dto/update-operations-standards.dto.ts index cbc686a22..3c45e2984 100644 --- a/apps/edr-freight-api/src/modules/operations-reporting/dto/update-operations-standards.dto.ts +++ b/apps/edr-freight-api/src/modules/operations-reporting/dto/update-operations-standards.dto.ts @@ -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) diff --git a/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-standard.entity.ts b/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-standard.entity.ts index d5a31725f..655209d71 100644 --- a/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-standard.entity.ts +++ b/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-standard.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts index 6da342e17..ffb138c22 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts @@ -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 { - 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 { + 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 train’s ' + - '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 corridor’s 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 leg’s 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) diff --git a/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts index 03f98d306..747a14891 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts @@ -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') diff --git a/apps/edr-freight-api/src/modules/reports/definitions/first-last-mile-bookings.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/first-last-mile-bookings.report.ts index d3abe10dc..5832e75f3 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/first-last-mile-bookings.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/first-last-mile-bookings.report.ts @@ -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 { - 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 { 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) diff --git a/apps/edr-freight-api/src/modules/reports/definitions/global-logistics-wagons.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/global-logistics-wagons.report.ts index d3fd759f7..c6a8edc7a 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/global-logistics-wagons.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/global-logistics-wagons.report.ts @@ -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 { - 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; } diff --git a/apps/edr-freight-api/src/modules/reports/definitions/loaded-capacity.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/loaded-capacity.report.ts index 8827b77b8..0c8f08c7f 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/loaded-capacity.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/loaded-capacity.report.ts @@ -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 { - const { params } = ctx; + const { params, directions } = ctx; const qb = ctx.ds .createQueryBuilder() .from(TrainSetWagon, 'tsw') @@ -24,6 +25,8 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder { 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') diff --git a/apps/edr-freight-api/src/modules/reports/definitions/loading-unloading.report.spec.ts b/apps/edr-freight-api/src/modules/reports/definitions/loading-unloading.report.spec.ts new file mode 100644 index 000000000..b5b72a06b --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/loading-unloading.report.spec.ts @@ -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')); + }); +}); diff --git a/apps/edr-freight-api/src/modules/reports/definitions/loading-unloading.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/loading-unloading.report.ts new file mode 100644 index 000000000..aa7564b39 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/loading-unloading.report.ts @@ -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 stop’s 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 stop’s 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', + }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/locomotive-fleet-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/locomotive-fleet-status.report.ts index cf971cf2c..a9e74039d 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/locomotive-fleet-status.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/locomotive-fleet-status.report.ts @@ -12,7 +12,10 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder { .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 }); diff --git a/apps/edr-freight-api/src/modules/reports/definitions/port-warehouse-summary.report.spec.ts b/apps/edr-freight-api/src/modules/reports/definitions/port-warehouse-summary.report.spec.ts new file mode 100644 index 000000000..2254cb53d --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/port-warehouse-summary.report.spec.ts @@ -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", + ]), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/reports/definitions/port-warehouse-summary.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/port-warehouse-summary.report.ts new file mode 100644 index 000000000..9cd3a11c8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/port-warehouse-summary.report.ts @@ -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.` is a runtime 42703 that + * neither tsc nor a type-check can see. + */ +const COUNTS: Record = { + 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.` 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 { + 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 { + 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 { + 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) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts index 0e07710bb..190e83c02 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts @@ -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') diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-reconciliation.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-reconciliation.report.ts index 73de3f505..c7bec9497 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/revenue-reconciliation.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-reconciliation.report.ts @@ -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') diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-transactions.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-transactions.report.ts index ad96b5b9e..9b060659c 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/revenue-transactions.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-transactions.report.ts @@ -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 { } 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) }, ]; }, }; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/station-staying-time.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/station-staying-time.report.ts index 3cf0464d8..e69f526fb 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/station-staying-time.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/station-staying-time.report.ts @@ -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 { - 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 { - 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 train’s ' + '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 stop’s 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) }, ]; }, diff --git a/apps/edr-freight-api/src/modules/reports/definitions/train-schedule-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/train-schedule-status.report.ts index 88a25d890..1104d5895 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/train-schedule-status.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/train-schedule-status.report.ts @@ -2,6 +2,7 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; import { TrainSchedule, TRAIN_SCHEDULE_STATUSES } from '../../train-schedules/entities/train-schedule.entity'; import { Yard } from '../../rule-engine/entities/yard.entity'; +import { applyDirectionScope } from '../../user-trade-access/trade-scope.util'; import { ReportContext, ReportDefinition } from '../report.types'; // ITLMS's spec lists Scheduled/Dispatched/In Transit/Arrived/Cancelled as the @@ -11,7 +12,7 @@ import { ReportContext, ReportDefinition } from '../report.types'; const STATUS_OPTIONS = TRAIN_SCHEDULE_STATUSES.map((v) => ({ value: v, label: v })); function baseQuery(ctx: ReportContext): SelectQueryBuilder { - const { params } = ctx; + const { params, directions } = ctx; const qb = ctx.ds .createQueryBuilder() .from(TrainSchedule, 'ts') @@ -28,6 +29,8 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder { if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction }); const statuses = params.statuses as string[] | null; if (statuses) qb.andWhere('ts.status IN (:...statuses)', { statuses }); + + applyDirectionScope(qb, 'ts.direction', directions); return qb; } @@ -76,7 +79,7 @@ export const trainScheduleStatusReport: ReportDefinition = { .addSelect('ts.direction', 'direction') .addSelect("COALESCE(o.label, 'Unknown')", 'origin') .addSelect("COALESCE(d.label, 'Unknown')", 'destination') - .addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD')`, 'scheduledDeparture') + .addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD HH24:MI')`, 'scheduledDeparture') .addSelect(`to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI')`, 'actualDeparture') .addSelect(`to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI')`, 'actualArrival'); }, diff --git a/apps/edr-freight-api/src/modules/reports/definitions/train-turnaround.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/train-turnaround.report.ts index 7dc7b8c3c..48304f054 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/train-turnaround.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/train-turnaround.report.ts @@ -2,6 +2,7 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; import { Yard } from '../../rule-engine/entities/yard.entity'; +import { applyDirectionScope } from '../../user-trade-access/trade-scope.util'; import { ReportContext, ReportDefinition } from '../report.types'; // "Turnaround" here is departure-to-arrival transit time on the actual (not @@ -9,7 +10,7 @@ import { ReportContext, ReportDefinition } from '../report.types'; // departure) would need pairing consecutive schedules by physical train, // which isn't tracked directly — deferred, not modeled as a shortcut. function baseQuery(ctx: ReportContext): SelectQueryBuilder { - const { params } = ctx; + const { params, directions } = ctx; const qb = ctx.ds .createQueryBuilder() .from(TrainSchedule, 'ts') @@ -22,6 +23,8 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder { if (params.dateFrom) qb.andWhere('ts.actual_departure_at >= :dateFrom', { dateFrom: params.dateFrom }); if (params.dateTo) qb.andWhere('ts.actual_departure_at < :dateTo', { dateTo: params.dateTo }); if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction }); + + applyDirectionScope(qb, 'ts.direction', directions); return qb; } diff --git a/apps/edr-freight-api/src/modules/reports/definitions/turnaround-cycle.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/turnaround-cycle.report.ts index 1a130e329..f874b1e0c 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/turnaround-cycle.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/turnaround-cycle.report.ts @@ -6,7 +6,10 @@ import { CYCLE_STANDARD_HOURS_EXPR, DIRECTION_FILTER, cycleRateExpr, + handlingEndOn, + handlingStartOn, hoursBetween, + otherActivityHours, scheduleLedgerQb, } from '../operations-classification'; @@ -61,6 +64,44 @@ const DJIBOUTI_HOURS = stayHours('Djibouti'); const AD_HOURS = hoursBetween('c.cycle_start', 'c.cycle_end'); const TRAVEL_HOURS = `ROUND(GREATEST((${AD_HOURS})::numeric - ${ETHIOPIA_HOURS} - ${DJIBOUTI_HOURS}, 0), 1)::float8`; +/** + * The leg the train leaves a stop on, which is the one it loads for. Inside a + * cycle the order is known outright — it arrives at the far end on the first + * schedule and departs on the return, arrives home on the return and departs on + * the next cycle's first leg — so the booking-derived loading window can be + * resolved here without the window function the stop-shaped query uses. + */ +const CYCLE_DEPARTING_SCHEDULE = `CASE + WHEN e.train_schedule_id = c.schedule_id THEN c.return_schedule_id + WHEN e.train_schedule_id = c.return_schedule_id THEN c.next_cycle_schedule_id +END`; + +const cycleHandlingStart = handlingStartOn('e', CYCLE_DEPARTING_SCHEDULE, 'e.yard_id'); +const cycleHandlingEnd = handlingEndOn('e', CYCLE_DEPARTING_SCHEDULE, 'e.yard_id'); + +/** + * Loading and unloading at the cycle's own stops, summed across both ends of + * the line. + * + * NULL — not zero — when no stop in the cycle has a handling window: SUM over + * no rows is NULL, and a cycle nobody measured has an unknown handling time. + * Other activity follows it, so such a cycle shows both columns empty rather + * than claiming the whole stay was other activity. + */ +const HANDLING_HOURS = `( + SELECT ROUND(SUM( + EXTRACT(EPOCH FROM (${cycleHandlingEnd} - ${cycleHandlingStart})) / 3600 + )::numeric, 1)::float8 + FROM freight.train_checkpoint_events e + WHERE e.deleted_at IS NULL + AND e.train_schedule_id IN (c.schedule_id, c.return_schedule_id, c.next_cycle_schedule_id) + AND ${cycleHandlingStart} IS NOT NULL + AND ${cycleHandlingEnd} IS NOT NULL +)`; + +const STATION_STAY_HOURS = `(${ETHIOPIA_HOURS} + ${DJIBOUTI_HOURS})`; +const OTHER_ACTIVITY_HOURS = otherActivityHours(STATION_STAY_HOURS, HANDLING_HOURS); + /** The completed cycles, before the per-cycle stay decomposition. */ function cycleQuery(ctx: ReportContext): SelectQueryBuilder { return scheduleLedgerQb(ctx) @@ -99,7 +140,10 @@ export const turnaroundCycleReport: ReportDefinition = { 'bulk via DMP, 96h via Negad or BCC — editable in Operating standards). Implement ' + 'rate is [(SC − AD) / SC + 1] × 100, so finishing exactly on standard scores 100. ' + 'The Ethiopia, Djibouti and travelling split comes from logged station checkpoints ' + - 'and reads zero for a train whose stops were never logged.', + 'and reads zero for a train whose stops were never logged. Loading and unloading is ' + + 'the handling logged at those stops, unloading start to loading end, summed over the ' + + 'cycle; other activity is the rest of the time standing at stations. Both read empty ' + + 'where no stop in the cycle recorded its handling.', group: 'Operations', filters: [ { key: 'date', label: 'Departure', type: 'daterange' }, @@ -116,6 +160,8 @@ export const turnaroundCycleReport: ReportDefinition = { { key: 'implementRate', label: 'Implement rate', type: 'percent', sortable: true }, { key: 'ethiopiaHours', label: 'Ethiopia stay (hrs)', type: 'number' }, { key: 'djiboutiHours', label: 'Djibouti stay (hrs)', type: 'number' }, + { key: 'loadUnloadHours', label: 'Loading + unloading (hrs)', type: 'number' }, + { key: 'otherActivityHours', label: 'Other activity (hrs)', type: 'number' }, { key: 'travellingHours', label: 'Travelling (hrs)', type: 'number' }, { key: 'averageDays', label: 'Average day', type: 'number' }, ], @@ -132,6 +178,8 @@ export const turnaroundCycleReport: ReportDefinition = { .addSelect(cycleRateExpr(`(${AD_HOURS})::numeric`, 'c.standard_hours'), 'implementRate') .addSelect(`${ETHIOPIA_HOURS}::float8`, 'ethiopiaHours') .addSelect(`${DJIBOUTI_HOURS}::float8`, 'djiboutiHours') + .addSelect(HANDLING_HOURS, 'loadUnloadHours') + .addSelect(OTHER_ACTIVITY_HOURS, 'otherActivityHours') .addSelect(TRAVEL_HOURS, 'travellingHours') .addSelect(`ROUND((${AD_HOURS})::numeric / 24, 2)::float8`, 'averageDays'); }, @@ -143,12 +191,14 @@ export const turnaroundCycleReport: ReportDefinition = { `ROUND(AVG(${cycleRateExpr(`(${AD_HOURS})::numeric`, 'c.standard_hours')}::numeric), 1)::float8`, 'avgRate', ) - .getRawOne<{ cycles: number; avgHours: number; avgRate: number }>(); + .addSelect(`ROUND(AVG((${HANDLING_HOURS})::numeric), 1)::float8`, 'avgHandling') + .getRawOne<{ cycles: number; avgHours: number; avgRate: number; avgHandling: number }>(); return [ { label: 'Cycles', value: Number(row?.cycles ?? 0) }, { label: 'Average duration', value: Number(row?.avgHours ?? 0), unit: 'h' }, { label: 'Average implement rate', value: Number(row?.avgRate ?? 0), unit: '%' }, + { label: 'Average loading + unloading', value: Number(row?.avgHandling ?? 0), unit: 'h' }, ]; }, }; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/wagon-requests.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/wagon-requests.report.ts index df5ac364c..67a916d45 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/wagon-requests.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/wagon-requests.report.ts @@ -57,8 +57,8 @@ export const wagonRequestsReport: ReportDefinition = { .addSelect('r.quantity', 'quantity') .addSelect('r.fulfilled_quantity', 'fulfilledQuantity') .addSelect('r.status', 'status') - .addSelect(`to_char(r.created_at, 'YYYY-MM-DD')`, 'requestedAt') - .addSelect(`to_char(r.fulfilled_at, 'YYYY-MM-DD')`, 'fulfilledAt') + .addSelect(`to_char(r.created_at, 'YYYY-MM-DD HH24:MI')`, 'requestedAt') + .addSelect(`to_char(r.fulfilled_at, 'YYYY-MM-DD HH24:MI')`, 'fulfilledAt') .addSelect( `ROUND(EXTRACT(EPOCH FROM (COALESCE(r.fulfilled_at, now()) - r.created_at))::numeric / 86400, 1)::float8`, 'delayDays', diff --git a/apps/edr-freight-api/src/modules/reports/definitions/wagon-status-duration.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/wagon-status-duration.report.ts index 348f681d1..771eec71d 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/wagon-status-duration.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/wagon-status-duration.report.ts @@ -69,7 +69,7 @@ export const wagonStatusDurationReport: ReportDefinition = { .addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType') .addSelect("COALESCE(y.label, 'Unassigned')", 'station') .addSelect('w.status', 'status') - .addSelect(`to_char(COALESCE(log.since, w.updated_at), 'YYYY-MM-DD')`, 'since') + .addSelect(`to_char(COALESCE(log.since, w.updated_at), 'YYYY-MM-DD HH24:MI')`, 'since') .addSelect( `FLOOR(EXTRACT(EPOCH FROM (now() - COALESCE(log.since, w.updated_at))) / 86400)::int`, 'daysInStatus', diff --git a/apps/edr-freight-api/src/modules/reports/definitions/wagon-teu-utilization.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/wagon-teu-utilization.report.ts index 7dabec37c..3d81c06ed 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/wagon-teu-utilization.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/wagon-teu-utilization.report.ts @@ -5,16 +5,21 @@ import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; import { Container } from '../../container-management/entities/container.entity'; import { ContainerType } from '../../rule-engine/entities/container-type.entity'; +import { applyDirectionScope } from '../../user-trade-access/trade-scope.util'; import { ReportContext, ReportDefinition } from '../report.types'; // TEU = container size in feet / 20 (20ft -> 1 TEU, 40ft -> 2 TEU). Scoped to // each wagon's CURRENT schedule pin — a live-state view, not a historical one. function baseQuery(ctx: ReportContext): SelectQueryBuilder { - const { params } = ctx; + const { params, directions } = ctx; const qb = ctx.ds .createQueryBuilder() .from(Wagon, 'w') - .innerJoin(TrainSchedule, 'ts', 'ts.id = w.current_train_schedule_id') + .innerJoin( + TrainSchedule, + 'ts', + 'ts.id = w.current_train_schedule_id AND ts.deleted_at IS NULL', + ) .leftJoin(WagonType, 'wt', 'wt.id = w.wagon_type_id') .leftJoin(Container, 'c', 'c.wagon_id = w.id AND c.deleted_at IS NULL') .leftJoin(ContainerType, 'ct', 'ct.id = c.container_type_id') @@ -27,6 +32,8 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder { 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; } @@ -53,7 +60,7 @@ export const wagonTeuUtilizationReport: ReportDefinition = { .select('w.wagon_number', 'wagonNumber') .addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType') .addSelect('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('COUNT(c.id)::int', 'containers') .addSelect('(COALESCE(SUM(ct.size_ft), 0) / 20.0)::float8', 'teu') .groupBy('w.wagon_number') diff --git a/apps/edr-freight-api/src/modules/reports/operations-classification.spec.ts b/apps/edr-freight-api/src/modules/reports/operations-classification.spec.ts index 63a7db20b..9dd31cdfd 100644 --- a/apps/edr-freight-api/src/modules/reports/operations-classification.spec.ts +++ b/apps/edr-freight-api/src/modules/reports/operations-classification.spec.ts @@ -2,12 +2,19 @@ import { CARGO_CATEGORIES, CARGO_CATEGORY_EXPR, CARGO_CATEGORY_LABEL_EXPR, + REVENUE_CARGO_CATEGORY_EXPR, CONTAINER_CLASSES, CONTAINER_CLASS_EXPR, + HANDLING_STANDARD_HOURS_EXPR, TARGET_DIMENSION_KEYS, cycleRateExpr, + handlingHours, implementRateExpr, + loadingHours, + otherActivityHours, + plannedRowsSql, } from './operations-classification'; +import { REVENUE_CATEGORIES } from './revenue-classification'; import { TARGET_DIMENSIONS, TARGET_METRICS } from '../operations-reporting/entities/operations-target.entity'; /** @@ -23,12 +30,61 @@ function emittedKeys(expr: string): string[] { } describe('operations classification', () => { + /** + * The loading window falls back to the bookings that boarded at the stop, off + * the DEPARTING schedule — a turnaround loads for the leg it leaves on, not + * the one it arrived on. Losing either half of that silently turns a + * populated report back into an empty one. + */ + it('falls back to the booking-derived loading window, off the departing leg', () => { + for (const expr of [loadingHours('s'), handlingHours('s')]) { + expect(expr).toContain('COALESCE(s.loading_started_at'); + expect(expr).toContain('s.departed_schedule_id'); + expect(expr).toContain('b.origin_yard_id = (s.yard_id)'); + } + }); + + /** Unloading is never derived — auto-unload would report it as ~0 hours. */ + it('never derives the unloading half', () => { + expect(handlingHours('s')).toContain('s.unloading_started_at'); + expect(handlingHours('s')).not.toContain('b.destination_yard_id'); + }); + + /** + * Every other standard coalesces to the spec's figure. This one must not: + * the spec names no handling standard, and a fallback would publish a rate + * against a number nobody agreed to. + */ + it('leaves the handling standard null when nobody has set one', () => { + expect(HANDLING_STANDARD_HOURS_EXPR).toContain('std.handling_standard_hours_container'); + expect(HANDLING_STANDARD_HOURS_EXPR).not.toContain('COALESCE(std.handling'); + }); + + /** An unmeasured stop reports unknown activity, not a full stay of it. */ + it('keeps other activity null when there is no handling window', () => { + expect(otherActivityHours('stay', 'handling')).toContain('IS NULL THEN NULL'); + }); + it('offers every cargo category the expression can emit as a filter option', () => { const offered = new Set(CARGO_CATEGORIES.map((o) => o.value)); const missing = [...new Set(emittedKeys(CARGO_CATEGORY_EXPR))].filter((k) => !offered.has(k)); expect(missing).toEqual([]); }); + /** + * The volume report groups tonnage by this expression and the finance reports + * group birr by `REVENUE_CATEGORY_EXPR`. A key only one side can emit is a + * bucket that never reconciles — and it fails silently, as a row that simply + * has no counterpart. + */ + it('classifies cargo into keys the revenue vocabulary offers', () => { + const offered = new Set(REVENUE_CATEGORIES.map((o) => o.value)); + const missing = [...new Set(emittedKeys(REVENUE_CARGO_CATEGORY_EXPR))].filter( + (k) => !offered.has(k), + ); + expect(missing).toEqual([]); + }); + it('offers every container class the expression can emit', () => { const offered = new Set(CONTAINER_CLASSES.map((o) => o.value)); const missing = [...new Set(emittedKeys(CONTAINER_CLASS_EXPR))].filter((k) => !offered.has(k)); @@ -91,4 +147,74 @@ describe('operations classification', () => { expect(rate(65, 78)).toBeLessThan(100); expect(cycleRateExpr('ad', 'sc')).toContain('NULLIF(sc, 0)'); }); + + /** + * The plan side is FULL OUTER JOINed to the operated side, so a plan row for + * a category the user filtered out comes back as a row of zeros — the bug + * where `?categories=FERTILIZER` still returned all ten planned categories. + */ + describe('plannedRowsSql cargo filter', () => { + const sqlFor = (dimension: string, params: Record): string => + plannedRowsSql('VOLUME_TONS', dimension, params, 'SELECT 1'); + + it('restricts targets to the selected categories', () => { + expect(sqlFor('cargo_category', { categories: ['FERTILIZER'] })).toContain( + "AND ot.dimension_key IN ('FERTILIZER')", + ); + }); + + it('restricts a station target on its cargo type, not its key', () => { + const sql = sqlFor('station', { categories: ['SAND'] }); + expect(sql).toContain("AND ot.cargo_category IN ('SAND')"); + expect(sql).not.toContain('ot.dimension_key IN'); + }); + + it('filters a container class report on its own vocabulary', () => { + expect(sqlFor('container_class', { classes: ['CONTAINER_EXPORT'] })).toContain( + "AND ot.dimension_key IN ('CONTAINER_EXPORT')", + ); + }); + + it('leaves every target when nothing is selected', () => { + expect(sqlFor('cargo_category', {})).not.toContain('ot.dimension_key IN'); + }); + + it('matches nothing on a value no category expression can emit', () => { + expect(sqlFor('cargo_category', { categories: ["x'; DROP TABLE"] })).toContain('AND FALSE'); + }); + + it('narrows a station plan to the chosen country rather than suppressing it', () => { + const sql = sqlFor('station', { country: 'Djibouti' }); + expect(sql).toContain("y.country = 'Djibouti'"); + expect(sql).not.toContain('AND FALSE'); + }); + + it('ignores a country that is not one of the two sides', () => { + expect(sqlFor('station', { country: "' OR true --" })).not.toContain('y.country'); + }); + + it('leaves the country alone on a plan not keyed by station', () => { + expect(sqlFor('cargo_category', { country: 'Djibouti' })).not.toContain('y.country'); + }); + + /** + * No target carries a route, a train or a direction, so beside a + * route-filtered actual the plan would be the whole corridor's target. + */ + it.each(['origin', 'destination', 'trainNumber', 'direction'])( + 'reports no plan at all when %s narrows below the target grain', + (key) => { + expect(sqlFor('cargo_category', { [key]: 'X' })).toContain('AND FALSE'); + }, + ); + + it('keeps the plan when only period, date and category are set', () => { + const sql = sqlFor('cargo_category', { + period: 'month', + dateFrom: '2026-01-01', + categories: ['SAND'], + }); + expect(sql).not.toContain('AND FALSE'); + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/reports/operations-classification.ts b/apps/edr-freight-api/src/modules/reports/operations-classification.ts index 1c8f8504c..3e4bc599d 100644 --- a/apps/edr-freight-api/src/modules/reports/operations-classification.ts +++ b/apps/edr-freight-api/src/modules/reports/operations-classification.ts @@ -3,13 +3,15 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; import { Booking } from '../bookings/entities/booking.entity'; import { OperationsStandard } from '../operations-reporting/entities/operations-standard.entity'; import { CargoType } from '../rule-engine/entities/cargo-type.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 { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; import { applyDirectionScope } from '../user-trade-access/trade-scope.util'; import { ReportContext, ReportFilterDef, ReportFilterOption } from './report.types'; -import { resolvePeriod, yardOptions } from './revenue-classification'; +import { REVENUE_CATEGORIES, resolvePeriod, yardOptions } from './revenue-classification'; /** * The shared vocabulary and SQL behind every operations report — turnaround, @@ -113,6 +115,39 @@ export const CARGO_CATEGORY_EXPR = `CASE ELSE 'UNCLASSIFIED' END`; +/** + * The same cargo, classified into the REVENUE vocabulary — the categories + * `revenue-classification.ts` bills against, minus its charge-only buckets + * (incidental, first/last mile, customs), which no physical wagon can be. + * + * Mirrors the cargo arms of `REVENUE_CATEGORY_EXPR` in that expression's own + * order, so a ton and the birr charged for it land in the same bucket: empty + * re-export before domestic, domestic before anything about what is in the box. + * Reports that must reconcile tonnage against revenue group by this one; the + * operational vocabulary above keeps sand and bulk apart, which no invoice does. + */ +export const REVENUE_CARGO_CATEGORY_EXPR = `CASE + WHEN ${IS_EMPTY_CONTAINER} THEN 'EMPTY_CONTAINER_REEXPORT' + WHEN oy.country IS NOT NULL AND oy.country = dy.country THEN 'DOMESTIC' + WHEN ${IS_CONTAINER} AND b.trade_direction = 'EXPORT' THEN 'CONTAINER_EXPORT' + WHEN ${IS_CONTAINER} AND ${IS_MULTIMODAL} THEN 'CONTAINER_IMPORT_MULTIMODAL' + WHEN ${IS_CONTAINER} THEN 'CONTAINER_IMPORT_UNIMODAL' + WHEN ct.code IN (${quote(FERTILIZER_CODES)}) THEN 'FERTILIZER' + WHEN ct.code IN (${quote(BREAK_BULK_CODES)}) THEN 'BREAK_BULK' + WHEN ct.code IN (${quote(RORO_CODES)}) THEN 'RORO' + WHEN b.trade_direction = 'EXPORT' THEN 'OTHER_EXPORT_CARGO' + WHEN b.trade_direction = 'IMPORT' THEN 'OTHER_IMPORT_BULK' + ELSE 'UNCLASSIFIED' +END`; + +/** The revenue vocabulary as a filter, plus the wagon that carries no cargo. */ +export const REVENUE_CARGO_FILTER: ReportFilterDef = { + key: 'categories', + label: 'Cargo type', + type: 'multiselect', + options: [...REVENUE_CATEGORIES, { value: 'EMPTY_WAGON', label: 'Empty wagon' }], +}; + export const CONTAINER_CLASS_EXPR = `CASE WHEN ${IS_EMPTY_CONTAINER} THEN 'EMPTY_CONTAINER_RETURN' WHEN b.trade_direction = 'EXPORT' THEN 'CONTAINER_EXPORT' @@ -205,6 +240,20 @@ END`; export const DELAY_TOLERANCE_HOURS_EXPR = `(${stdRow('delay_tolerance_minutes', 30)} / 60.0)`; +/** + * Standard loading-and-unloading time for a stop, by what the train carries. + * + * Deliberately NOT wrapped in a fallback like every other standard here: the + * reporting spec publishes no figure for handling, so there is nothing honest + * to fall back to. Until a planner enters one in Operating standards this is + * NULL, and the rate and verdict that read it stay empty rather than judging a + * train against a number nobody agreed to. + */ +export const HANDLING_STANDARD_HOURS_EXPR = `CASE + WHEN ${SCHEDULE_IS_CONTAINER} THEN std.handling_standard_hours_container + ELSE std.handling_standard_hours_bulk +END`; + /** * Joins the single standards row. Restricted by id to the earliest live row so * a stray second row could never fan a report's result out. @@ -314,23 +363,13 @@ export const CHARGED_TONS_EXPR = `( * ${stdAgg('charged_tons_per_wagon_general', 70)} )::float8`; -/** Wagons actually carrying cargo in the grouped set. */ -export const LOADED_WAGONS_EXPR = 'COUNT(DISTINCT tsw.id)::int'; - /** - * Wagons on the departure with nothing allocated to them — the Vehicle-Km base. - * - * A train-level figure: it belongs to the departure, not to any one cargo type - * riding on it, so a report grouped finer than the schedule repeats it rather - * than splitting it. Callers that need a total must de-duplicate by schedule. + * Wagons actually carrying cargo in the grouped set. The FILTER only bites on a + * query built with `includeEmptyWagons` — every row of an allocation-grain + * query has an allocation, so it is a no-op there. */ -export const SCHEDULE_EMPTY_WAGONS = `( - SELECT COUNT(*) FROM freight.train_set_wagons tw - WHERE tw.train_set_id = ts.train_set_id AND tw.deleted_at IS NULL - AND NOT EXISTS ( - SELECT 1 FROM freight.wagon_booking_allocations a - WHERE a.train_set_wagon_id = tw.id AND a.deleted_at IS NULL) -)`; +export const LOADED_WAGONS_EXPR = + 'COUNT(DISTINCT tsw.id) FILTER (WHERE wba.id IS NOT NULL)::int'; /** * Trainsets operated: wagons loaded divided by a full trainset for this cargo. @@ -424,21 +463,41 @@ const DEAD_SCHEDULE_STATUSES = ['DRAFT', 'CANCELLED']; * * The booking is LEFT joined — a wagon can be allocated before its booking data * is complete, and dropping those rows would understate wagon usage. + * + * `includeEmptyWagons` turns the ledger around to start from the wagon instead: + * every wagon of the departure is a row, and one that carried nothing has a + * NULL `wba`. Only the volume report wants that — it reports the empty wagons + * as their own line — and it costs the other reports a row grain they would + * have to filter back out. */ -export function allocationLedgerQb(ctx: ReportContext): SelectQueryBuilder { +export function allocationLedgerQb( + ctx: ReportContext, + opts: { includeEmptyWagons?: boolean } = {}, +): SelectQueryBuilder { const { params, directions } = ctx; - const qb = ctx.ds - .createQueryBuilder() - .from(WagonBookingAllocation, 'wba') - .innerJoin(TrainSetWagon, 'tsw', 'tsw.id = wba.train_set_wagon_id AND tsw.deleted_at IS NULL') - .innerJoin(TrainSchedule, 'ts', 'ts.train_set_id = tsw.train_set_id AND ts.deleted_at IS NULL') + const qb = ctx.ds.createQueryBuilder(); + + if (opts.includeEmptyWagons) { + qb.from(TrainSetWagon, 'tsw') + .leftJoin( + WagonBookingAllocation, + 'wba', + 'wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL', + ) + .where('tsw.deleted_at IS NULL'); + } else { + qb.from(WagonBookingAllocation, 'wba') + .innerJoin(TrainSetWagon, 'tsw', 'tsw.id = wba.train_set_wagon_id AND tsw.deleted_at IS NULL') + .where('wba.deleted_at IS NULL'); + } + + qb.innerJoin(TrainSchedule, 'ts', 'ts.train_set_id = tsw.train_set_id AND ts.deleted_at IS NULL') .leftJoin(Booking, 'b', 'b.id = wba.booking_id AND b.deleted_at IS NULL') .leftJoin(CargoType, 'ct', 'ct.id = b.cargo_type_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('wba.deleted_at IS NULL') .andWhere('ts.status NOT IN (:...deadScheduleStatuses)', { deadScheduleStatuses: DEAD_SCHEDULE_STATUSES, }); @@ -471,6 +530,210 @@ export function scheduleLedgerQb(ctx: ReportContext): SelectQueryBuilder `( + SELECT ${agg}(b.loaded_at) + FROM freight.bookings b + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL + AND tsb.train_schedule_id = (${scheduleExpr}) + WHERE b.deleted_at IS NULL + AND b.origin_yard_id = (${yardExpr}) + AND b.loaded_at IS NOT NULL +)`; + +/** + * The departing schedule and the yard are passed in rather than read off a + * fixed alias: the stop-shaped query carries them as columns, while the + * turnaround report reads raw `train_checkpoint_events` rows and works out the + * departing leg from the cycle it already knows. + */ +export const loadingStartOn = ( + alias: string, + scheduleExpr: string, + yardExpr: string, +): string => + `COALESCE(${alias}.loading_started_at, ${derivedLoading('MIN', scheduleExpr, yardExpr)})`; +export const loadingEndOn = (alias: string, scheduleExpr: string, yardExpr: string): string => + `COALESCE(${alias}.loading_completed_at, ${derivedLoading('MAX', scheduleExpr, yardExpr)})`; + +/** The stop-shaped query's own columns — what every stay-based report uses. */ +const STOP_SCHEDULE = (alias: string): string => `${alias}.departed_schedule_id`; +const STOP_YARD = (alias: string): string => `${alias}.yard_id`; + +/** Hand-recorded times win; the booking-derived window is the fallback. */ +export const loadingStart = (alias: string): string => + loadingStartOn(alias, STOP_SCHEDULE(alias), STOP_YARD(alias)); +export const loadingEnd = (alias: string): string => + loadingEndOn(alias, STOP_SCHEDULE(alias), STOP_YARD(alias)); + +/** Which of the two the loading columns came from, so nobody mistakes one for the other. */ +export const loadingSource = (alias: string): string => `CASE + WHEN ${alias}.loading_started_at IS NOT NULL + OR ${alias}.loading_completed_at IS NOT NULL THEN 'Logged' + WHEN ${derivedLoading('MIN', STOP_SCHEDULE(alias), STOP_YARD(alias))} IS NOT NULL THEN 'Derived' + ELSE '—' +END`; + +/** + * When station work began and ended at a stop. + * + * LEAST and GREATEST ignore nulls, so a stop that only loaded (an export + * origin) or only unloaded reports that half's window on its own, and where + * both halves are known the pair spans exactly what the spec measures — + * unloading start to loading end. NULL when nothing was logged or derived: an + * unlogged stop has an unknown handling time, not a zero one. + */ +export const handlingStartOn = ( + alias: string, + scheduleExpr: string, + yardExpr: string, +): string => + `LEAST(${alias}.unloading_started_at, ${loadingStartOn(alias, scheduleExpr, yardExpr)})`; +export const handlingEndOn = (alias: string, scheduleExpr: string, yardExpr: string): string => + `GREATEST(${loadingEndOn(alias, scheduleExpr, yardExpr)}, ${alias}.unloading_completed_at)`; + +export const handlingStart = (alias: string): string => + handlingStartOn(alias, STOP_SCHEDULE(alias), STOP_YARD(alias)); +export const handlingEnd = (alias: string): string => + handlingEndOn(alias, STOP_SCHEDULE(alias), STOP_YARD(alias)); + +/** Total loading and unloading time at a stop, in hours. */ +export const handlingHours = (alias: string): string => + hoursBetween(handlingStart(alias), handlingEnd(alias)); + +export const unloadingHours = (alias: string): string => + hoursBetween(`${alias}.unloading_started_at`, `${alias}.unloading_completed_at`); +export const loadingHours = (alias: string): string => + hoursBetween(loadingStart(alias), loadingEnd(alias)); + +/** + * What the stay was spent on other than handling — the spec's "other activity". + * + * Stays NULL when handling was never logged rather than collapsing to the whole + * stay, and floors at zero: handling logged slightly outside the arrival and + * departure pair is a sloppy entry, not negative activity. + */ +export const otherActivityHours = (stayExpr: string, handlingExpr: string): string => + `CASE WHEN (${handlingExpr}) IS NULL THEN NULL + ELSE ROUND(GREATEST((${stayExpr})::numeric - (${handlingExpr})::numeric, 0), 1)::float8 END`; + +/** + * Every logged stop, with the event that followed it at the same station and + * whatever loading and unloading was recorded against it. + * + * Shared by the staying-time and loading-and-unloading reports so "a stop" + * means one thing across the suite. + */ +export function stationStopsQb(ctx: ReportContext): SelectQueryBuilder { + 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.code, '—')", 'station_code') + .addSelect("COALESCE(y.country, '—')", 'country') + .addSelect('ev.kind', 'kind') + .addSelect('ev.yard_id', 'yard_id') + .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') + // The leg the train LEAVES on, which is the one it loads for. A turnaround + // departs on a different schedule than it arrived on, so the booking-derived + // loading window has to follow this rather than `ev.train_schedule_id`. + .addSelect(`lead(ev.train_schedule_id) OVER (${STAY_WINDOW})`, 'departed_schedule_id') + // Handling rides the arrival row, which is the row a stay is built from. + .addSelect('ev.unloading_started_at', 'unloading_started_at') + .addSelect('ev.unloading_completed_at', 'unloading_completed_at') + .addSelect('ev.loading_started_at', 'loading_started_at') + .addSelect('ev.loading_completed_at', 'loading_completed_at') + // Classed by the leg that ARRIVED. A stop whose inbound and outbound legs + // differ in type is rare and reads as what pulled in. + .addSelect(`CASE WHEN ${SCHEDULE_IS_CONTAINER} THEN 'Container' ELSE 'Bulk' END`, 'train_type') + .addSelect(`ROUND(${STATION_STANDARD_HOURS_EXPR}, 1)`, 'standard_hours') + .addSelect(`ROUND(${HANDLING_STANDARD_HOURS_EXPR}, 1)`, 'handling_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 }); + // Whitelisted, not bound: the same EXISTS has to read identically in the + // SELECT above, and a bound parameter cannot be reused across both. + if (params.trainType === 'CONTAINER') qb.andWhere(SCHEDULE_IS_CONTAINER); + if (params.trainType === 'BULK') qb.andWhere(`NOT ${SCHEDULE_IS_CONTAINER}`); + + applyDirectionScope(qb, 'ts.direction', directions); + return qb; +} + +export const TRAIN_TYPE_FILTER: ReportFilterDef = { + key: 'trainType', + label: 'Train type', + type: 'select', + options: [ + { value: 'CONTAINER', label: 'Container' }, + { value: 'BULK', label: 'Bulk' }, + ], +}; + +/** Only completed stops — an arrival whose departure was also logged, alias `s`. */ +export function stationStaysQb(ctx: ReportContext): SelectQueryBuilder { + const inner = stationStopsQb(ctx); + return ctx.ds + .createQueryBuilder() + .from(`(${inner.getQuery()})`, 's') + .setParameters(inner.getParameters()) + .where("s.kind = 'ARRIVED'") + .andWhere("s.next_kind = 'DEPARTED'"); +} + export function applyOperationsFilters( qb: SelectQueryBuilder, params: Record, @@ -495,10 +758,11 @@ export function applyOperationsFilters( export function applyCategoryFilter( qb: SelectQueryBuilder, params: Record, + categoryExpr: string = CARGO_CATEGORY_EXPR, ): void { const categories = params.categories as string[] | null; if (categories?.length) { - qb.andWhere(`${CARGO_CATEGORY_EXPR} IN (:...categories)`, { categories }); + qb.andWhere(`${categoryExpr} IN (:...categories)`, { categories }); } } @@ -513,7 +777,9 @@ export const PLAN_GRANULARITY_NOTE = 'Plan is the committed figure and never moves. Required is the same target treated as a ' + 'quota: whatever is still outstanding, spread across the time still left, so a period ' + 'that fell behind raises what the periods after it must carry. A target already met in ' + - 'full requires nothing further.'; + 'full requires nothing further. No target carries a route, a train or a direction, so ' + + 'filtering by one leaves the plan columns empty rather than comparing a corridor’s whole ' + + 'target against one slice of its work.'; /** * The user's date filter as open-ended bounds, so the clipping arithmetic below @@ -575,6 +841,60 @@ END`; * Period bounds ride on `:planFrom` / `:planTo`, which the caller must bind * with {@link plannedRowsParams} — they come from the user's date filter. */ +/** + * The plan side of a plan-versus-actual report has to obey the same cargo + * filter the operated side does. Without it the FULL OUTER JOIN re-introduces + * every planned key the user filtered out, as a row of zeros. + * + * Values are whitelisted against the vocabulary and inlined rather than bound: + * this fragment is assembled into raw CTE text, and the filter params are not + * validated upstream. An unknown value matches nothing — same as it does on the + * operated side, where the CASE can never emit it. + */ +const planKeyFilter = (dimension: string, params: Record): string => { + const isClass = dimension === 'container_class'; + const selected = (isClass ? params.classes : params.categories) as string[] | null; + if (!selected?.length) return ''; + const vocab = isClass ? CONTAINER_CLASSES : CARGO_CATEGORIES; + const valid = selected.filter((v) => vocab.some((o) => o.value === v)); + // A station target is keyed on the yard and carries its cargo type alongside. + const column = dimension === 'station' ? 'ot.cargo_category' : 'ot.dimension_key'; + return valid.length ? `AND ${column} IN (${quote(valid)})` : 'AND FALSE'; +}; + +/** + * Filters that narrow the operated population below the grain any target is + * kept at. No target carries a route, a train or a direction, so a plan read + * beside a route-filtered actual is the whole corridor's plan sitting next to + * one slice of its work — the implement rate then reads as a miss that never + * happened. + * + * There is no honest number to show, so the plan side reports nothing at all + * and Implement Rate goes NULL, the same way it does for a period with no + * target. `country` is absent on purpose: on a station plan it is a property of + * the planned key itself, and {@link planCountryFilter} narrows rather than + * suppresses. + */ +const PLAN_GRAIN_BREAKERS = ['origin', 'destination', 'trainNumber', 'direction']; + +const planGrainFilter = (params: Record): string => + PLAN_GRAIN_BREAKERS.some((key) => params[key]) ? 'AND FALSE' : ''; + +/** + * A station target is keyed on a yard code, so the country filter — which + * decides which end of the corridor the report calls "the station" — is a real + * predicate on the plan, not a grain break. Without it the Djibouti view lists + * every Ethiopian station's target as a row that moved nothing. + */ +const planCountryFilter = (dimension: string, params: Record): string => { + if (dimension !== 'station') return ''; + const country = COUNTRY_FILTER.options?.find((o) => o.value === params.country)?.value; + if (!country) return ''; + return `AND EXISTS (SELECT 1 FROM freight.yards y + WHERE y.code = ot.dimension_key AND y.deleted_at IS NULL + AND y.country = '${country}')`; +}; + export const plannedRowsSql = ( metric: string, dimension: string, @@ -597,6 +917,9 @@ export const plannedRowsSql = ( AND ot.metric = '${metric}' AND ot.dimension = '${dimension}' AND ot.planned_value > 0 + ${planKeyFilter(dimension, params)} + ${planCountryFilter(dimension, params)} + ${planGrainFilter(params)} ), -- One row per target per bucket. Generated a day at a time rather than a -- bucket at a time: the ragged units restart their blocks each January, so diff --git a/apps/edr-freight-api/src/modules/reports/report-runner.service.ts b/apps/edr-freight-api/src/modules/reports/report-runner.service.ts index d38a5ba8a..350fdc4ee 100644 --- a/apps/edr-freight-api/src/modules/reports/report-runner.service.ts +++ b/apps/edr-freight-api/src/modules/reports/report-runner.service.ts @@ -7,7 +7,7 @@ import { normalizePagination, } from '../../common/utils/pagination.util'; import { applyBookingRefDirectionScope } from '../user-trade-access/trade-scope.util'; -import { ReportDefinition, ReportRunResult } from './report.types'; +import { ReportColumn, ReportDefinition, ReportRunResult } from './report.types'; const DAY_MS = 24 * 60 * 60 * 1000; @@ -38,7 +38,7 @@ function coerceParams( const items = csv?.split(',').map((s) => s.trim()).filter(Boolean) ?? []; params[filter.key] = items.length ? items : null; } else { - params[filter.key] = raw[filter.key]?.trim() || null; + params[filter.key] = raw[filter.key]?.trim() || filter.defaultValue || null; } } // idKey, when the report declares one, is a plain string param. @@ -57,19 +57,34 @@ function coerceParams( */ const aliasSortExpr = (key: string): string => `"${key.replace(/"/g, '""')}"`; +/** + * Columns the current filter values don't hide. A hidden column is not in the + * SELECT list of the shape those filters produce, so sorting by one would be a + * 42703 — the sort falls back to the default instead. + */ +export const visibleColumns = ( + def: ReportDefinition, + params: Record, +): ReportColumn[] => + def.columns.filter((c) => + Object.entries(c.hideWhen ?? {}).every(([key, value]) => params[key] !== value), + ); + /** Resolve a client-requested sort column against the report's own whitelist. */ function resolveSort( def: ReportDefinition, + params: Record, sortBy?: string, sortOrder?: string, ): { key: string; expr: string; dir: 'ASC' | 'DESC' } | null { const dir = sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; - const requested = sortBy && def.columns.find((c) => c.key === sortBy && c.sortable); + const columns = visibleColumns(def, params); + const requested = sortBy && columns.find((c) => c.key === sortBy && c.sortable); if (requested) { return { key: requested.key, expr: requested.sortExpr ?? aliasSortExpr(requested.key), dir }; } if (!def.defaultSort) return null; - const fallback = def.columns.find((c) => c.key === def.defaultSort!.key); + const fallback = columns.find((c) => c.key === def.defaultSort!.key); if (!fallback) return null; return { key: fallback.key, @@ -91,7 +106,7 @@ export class ReportRunnerService { const ctx = { ds: this.ds, params, directions }; const qb = def.query(ctx); - const sort = resolveSort(def, raw.sortBy, raw.sortOrder); + const sort = resolveSort(def, params, raw.sortBy, raw.sortOrder); if (sort) qb.orderBy(sort.expr, sort.dir); const { page: pageNum, pageSize, skip, take } = normalizePagination({ @@ -141,7 +156,7 @@ export class ReportRunnerService { const qb = def.query(ctx); // Same sort the on-screen table is using, not always the default — an // export is supposed to match what the user is looking at. - const sort = resolveSort(def, raw.sortBy, raw.sortOrder); + const sort = resolveSort(def, params, raw.sortBy, raw.sortOrder); if (sort) qb.orderBy(sort.expr, sort.dir); const ceiling = limit ?? cap; diff --git a/apps/edr-freight-api/src/modules/reports/report.registry.ts b/apps/edr-freight-api/src/modules/reports/report.registry.ts index fc404738d..1235f0c83 100644 --- a/apps/edr-freight-api/src/modules/reports/report.registry.ts +++ b/apps/edr-freight-api/src/modules/reports/report.registry.ts @@ -33,6 +33,8 @@ import { teuPerformanceReport } from "./definitions/teu-performance.report"; import { cargoVolumePerformanceReport } from "./definitions/cargo-volume-performance.report"; import { chargedVsActualVolumeReport } from "./definitions/charged-vs-actual-volume.report"; import { cargoVolumeByStationReport } from "./definitions/cargo-volume-by-station.report"; +import { portWarehouseSummaryReport } from "./definitions/port-warehouse-summary.report"; +import { loadingUnloadingReport } from "./definitions/loading-unloading.report"; import { ReportDefinition } from "./report.types"; /** @@ -75,6 +77,8 @@ export const REPORTS: ReportDefinition[] = [ cargoVolumePerformanceReport, chargedVsActualVolumeReport, cargoVolumeByStationReport, + portWarehouseSummaryReport, + loadingUnloadingReport, ]; const BY_KEY = new Map( diff --git a/apps/edr-freight-api/src/modules/reports/report.types.ts b/apps/edr-freight-api/src/modules/reports/report.types.ts index ca7578461..7f1758164 100644 --- a/apps/edr-freight-api/src/modules/reports/report.types.ts +++ b/apps/edr-freight-api/src/modules/reports/report.types.ts @@ -19,6 +19,12 @@ export interface ReportColumn { sortable?: boolean; /** SQL to ORDER BY when this column is sorted, if different from `key`. */ sortExpr?: string; + /** + * Hide the column while a filter holds a given value — how one report serves + * two group-by grains without two column lists. Display only: the value is + * still selected, exported and sortable, it just isn't shown. + */ + hideWhen?: Record; } export type ReportFilterType = 'daterange' | 'date' | 'select' | 'multiselect' | 'text'; @@ -34,6 +40,12 @@ export interface ReportFilterDef { type: ReportFilterType; /** Static option list for select/multiselect. */ options?: ReportFilterOption[]; + /** + * Value the filter takes when the client sends nothing — so a report whose + * shape depends on a filter (see `ReportColumn.hideWhen`) never has to guess + * what "unset" meant. + */ + defaultValue?: string; /** * Resolves the option list from the database instead of declaring it inline — * for filters whose choices are reference data (stations, cargo types). diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts index ee64768c2..fd7754844 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts @@ -1,7 +1,12 @@ import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity'; -/** How the bulk commodity a rate is scoped to is counted (cargo_types.unit_of_measure). */ -export type CargoUom = 'PER_TON' | 'PER_ITEM' | null | undefined; +/** + * How the bulk commodity a rate is scoped to is counted + * (cargo_types.unit_of_measure). NUMBER_OF_WAGONS cargo is weighed in tons and + * offers the same PER_TON / PER_WAGON units as PER_TON cargo — only the + * booking form (which also asks for a wagon count) treats it differently. + */ +export type CargoUom = 'PER_TON' | 'PER_ITEM' | 'NUMBER_OF_WAGONS' | null | undefined; /** * Units billed against a booking's bulk quantity. That quantity is recorded in diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts index c530e5314..2d91d2da7 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts @@ -169,6 +169,101 @@ describe('RuleEngineService — overweight surcharge by trade direction', () => }); }); +describe('RuleEngineService — overweight is per container, never pooled', () => { + const configuredOverweight: Rate = { + id: 'rate-ow', + rateType: 'OVERWEIGHT_PER_TON', + trigger: 'OVERWEIGHT', + rateValue: 10, + rateUnit: 'PER_TON', + currency: 'USD', + status: 'LIVE', + containerTypeId: null, + cargoTypeId: null, + } as Rate; + + const makeService = (maxCapacityTons: number | null) => + new RuleEngineService( + { findById: jest.fn().mockResolvedValue(null) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + { + findActiveByContainerTypeId: jest + .fn() + .mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons }]), + } as never, + { findAllActive: jest.fn().mockResolvedValue([]) } as never, + { findLiveRates: jest.fn().mockResolvedValue([configuredOverweight]) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + {} as never, + ); + + // The reported case: 3 × 20ft at 22 / 18 / 20 t against a 20 t limit. The + // line total (60 t) fits a pooled 3 × 20 t allowance, but the first box is + // 2 t over and must be billed for it. + const input = (unitVgmTons: number[]): BookingEvaluationInput => ({ + serviceTypeId: 'svc-1', + paymentCurrency: 'USD', + tradeDirection: 'EXPORT', + isHazardous: false, + totalWagons: 2, + containers: [ + { + containerTypeId: 'ct-20', + quantity: unitVgmTons.length, + vgmPerUnitTons: unitVgmTons.reduce((s, v) => s + v, 0) / unitVgmTons.length, + totalVgmTons: unitVgmTons.reduce((s, v) => s + v, 0), + unitVgmTons, + }, + ], + }); + + it('bills only the tons the heavy container is over, not the line total', async () => { + const result = await makeService(null).evaluate(input([22, 18, 20])); + const wr = result.containerWeightResults[0]; + expect(wr.isOverweight).toBe(true); + expect(wr.overweightExcessTons).toBe(2); + expect(wr.overweightUnits).toEqual([{ unitIndex: 1, vgmTons: 22, excessTons: 2 }]); + const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON'); + expect(ow[0].calculatedAmount).toBe(20); // 2 t × 10 USD/t + }); + + it('sums the excess of every over-limit container', async () => { + const result = await makeService(null).evaluate(input([22, 18, 23])); + const wr = result.containerWeightResults[0]; + expect(wr.overweightExcessTons).toBe(5); + expect(wr.overweightUnits?.map((u) => u.unitIndex)).toEqual([1, 3]); + }); + + it('is not overweight when no single container is over the limit', async () => { + const result = await makeService(null).evaluate(input([20, 18, 20])); + expect(result.containerWeightResults[0].isOverweight).toBe(false); + }); + + it('falls back to an even spread when a line carries no per-unit weights', async () => { + const result = await makeService(null).evaluate({ + serviceTypeId: 'svc-1', + paymentCurrency: 'USD', + tradeDirection: 'EXPORT', + isHazardous: false, + totalWagons: 2, + containers: [ + { containerTypeId: 'ct-20', quantity: 3, vgmPerUnitTons: 21, totalVgmTons: 63 }, + ], + }); + // 3 boxes at 21 t each → 1 t over on each. + expect(result.containerWeightResults[0].overweightExcessTons).toBe(3); + }); + + it('blocks on capacity per container, not on the pooled line total', async () => { + const violations = await makeService(30).capacityViolations( + [{ containerTypeId: 'ct-20', quantity: 3, totalVgmTons: 60, unitVgmTons: [35, 5, 20] }], + 'EXPORT', + ); + expect(violations).toHaveLength(1); + expect(violations[0]).toContain('#1'); + }); +}); + describe('RuleEngineService — empty-container return per route + container type', () => { const returnRate20: Rate = { id: 'rate-return-20', diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 5c9055415..3fbecc036 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -33,6 +33,32 @@ import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants'; // from multipart form-data) and a non-empty "false" string is truthy. const truthy = (v: unknown): boolean => v === true || v === 'true'; +/** Tons carry 3 decimals in the schema; keep derived tonnage on that grid. */ +const round3 = (n: number): number => Math.round(n * 1000) / 1000; + +/** + * The VGM of every physical container on a line. Uses the per-unit weights the + * booking recorded; when a line has none (or fewer than its quantity — legacy + * rows only kept a line total), the remainder is spread evenly, which is the + * uniform load those bookings were entered as. + */ +export const unitWeights = (container: { + quantity: number; + totalVgmTons: number; + unitVgmTons?: number[]; +}): number[] => { + const known = (container.unitVgmTons ?? []) + .slice(0, container.quantity) + .map((v) => Number(v ?? 0)); + const missing = Math.max(0, Number(container.quantity || 0) - known.length); + if (missing === 0) return known; + const rest = Math.max( + 0, + Number(container.totalVgmTons || 0) - known.reduce((s, v) => s + v, 0), + ); + return [...known, ...Array(missing).fill(rest / missing)]; +}; + export interface BookingContainerEvalInput { containerTypeId: string; quantity: number; @@ -41,6 +67,15 @@ export interface BookingContainerEvalInput { isReefer?: boolean; isOverweight?: boolean; overweightExcessTons?: number | null; + /** + * VGM of each physical container on this line, when the booking carries + * per-unit weights. Weight limits are a per-container ceiling: 3x20ft at + * 22/18/20t against a 20t limit is 2t overweight on the first box, not + * zero because the line total happens to fit. Missing/short (legacy lines + * that only carry a line total) falls back to an even spread across + * `quantity`, which is what those bookings actually recorded. + */ + unitVgmTons?: number[]; /** * How many individual containers on this line opted into each handling * service. PER_CONTAINER surcharges bill these counts, not the line @@ -131,11 +166,22 @@ export interface AppliedCargoModifier { billingUnit?: string; } +/** One physical container that broke the per-container VGM limit. */ +export interface OverweightUnit { + /** 1-based position of the container within its line. */ + unitIndex: number; + vgmTons: number; + excessTons: number; +} + export interface ContainerWeightResult { containerTypeId: string; weightLimitRuleId: string | null; isOverweight: boolean; + /** Sum of the per-container excesses on this line. */ overweightExcessTons: number | null; + /** Which containers of the line are over, and by how much. */ + overweightUnits?: OverweightUnit[]; } export interface RuleEvaluationResult { @@ -221,22 +267,37 @@ export class RuleEngineService { lineMaxVgmTons.push(rule ? Number(rule.maxVgmTons) : null); let isOverweight = container.isOverweight ?? false; let excess = container.overweightExcessTons ?? null; + let overweightUnits: OverweightUnit[] | undefined; if (rule) { - const maxTotal = Number(rule.maxVgmTons) * container.quantity; - const totalVgm = container.totalVgmTons; - if (totalVgm > maxTotal) { + const perUnitLimit = Number(rule.maxVgmTons); + // Per-container, never pooled: an underloaded box does not absorb the + // excess of an overloaded one — each container is billed on its own + // tons above the limit. + overweightUnits = unitWeights(container) + .map((vgmTons, i) => ({ + unitIndex: i + 1, + vgmTons, + excessTons: round3(Math.max(0, vgmTons - perUnitLimit)), + })) + .filter((u) => u.excessTons > 0); + if (overweightUnits.length > 0) { isOverweight = true; - excess = Math.max(0, totalVgm - maxTotal); - warnings.push( - `Container type ${container.containerTypeId} VGM ${totalVgm}t exceeds limit ${maxTotal}t`, + excess = round3( + overweightUnits.reduce((sum, u) => sum + u.excessTons, 0), ); + for (const u of overweightUnits) { + warnings.push( + `Container type ${container.containerTypeId} #${u.unitIndex} VGM ${u.vgmTons}t exceeds the ${perUnitLimit}t limit by ${u.excessTons}t`, + ); + } } containerWeightResults.push({ containerTypeId: container.containerTypeId, weightLimitRuleId: rule.id, isOverweight, overweightExcessTons: excess, + overweightUnits, }); } else { containerWeightResults.push({ @@ -719,6 +780,7 @@ export class RuleEngineService { containerTypeId: string; quantity: number; totalVgmTons: number; + unitVgmTons?: number[]; }>, tradeDirection: string, ): Promise { @@ -731,13 +793,17 @@ export class RuleEngineService { const rule = rules[0]; if (!rule || rule.maxCapacityTons == null) continue; const perUnit = Number(rule.maxCapacityTons); - const maxTotal = perUnit * container.quantity; - if (container.totalVgmTons > maxTotal) { - const label = rule.containerType?.code ?? container.containerTypeId; - violations.push( - `${label} total weight ${container.totalVgmTons}t exceeds the maximum capacity of ${maxTotal}t (${perUnit}t per unit) — the booking cannot be created; reduce the cargo weight`, - ); - } + const label = rule.containerType?.code ?? container.containerTypeId; + // Capacity is a physical ceiling on one box, so it is checked per box for + // the same reason the VGM limit is — a light container cannot carry the + // overload of a heavy one. + unitWeights(container).forEach((vgmTons, i) => { + if (vgmTons > perUnit) { + violations.push( + `${label} #${i + 1} weight ${round3(vgmTons)}t exceeds the maximum capacity of ${perUnit}t per container — the booking cannot be created; reduce the cargo weight`, + ); + } + }); } return violations; } diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.service.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.service.ts index ce5a7fad8..c43781c43 100644 --- a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.service.ts +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.service.ts @@ -484,6 +484,13 @@ export class ShippingLineBookingCompletionService { returnQuantity: 0, vgmPerUnitTons: figures.vgmPerUnit, totalVgmTons: figures.totalVgm, + // In-memory units so the probe prices the same per-container + // overweight the persisted booking will: the limit applies to each + // box, not to the line's pooled tonnage. + units: (line.units ?? []).map((u, idx) => ({ + vgmTons: Number(u.vgmTons ?? 0), + sortOrder: idx, + })) as BookingContainer['units'], wagonsRequired: Math.ceil( line.quantity * wagonsPerUnitForSize(containerType.sizeFt), ), diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index fdd76861d..a1f927a77 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -21,6 +21,19 @@ export const TRAIN_SCHEDULE_STATUSES = [ export type TrainScheduleStatus = (typeof TRAIN_SCHEDULE_STATUSES)[number]; +/** One clicked loading or unloading window at a yard (ISO timestamps). */ +export interface StationWorkPhaseLog { + startedAt?: string | null; + endedAt?: string | null; + startedByUserId?: string | null; + endedByUserId?: string | null; +} + +export interface StationWorkLog { + loading?: StationWorkPhaseLog; + unloading?: StationWorkPhaseLog; +} + @Entity({ schema: 'freight', name: 'train_schedules' }) @Index(['scheduledDepartureDate']) @Index(['status']) @@ -157,6 +170,15 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'planned_wagon_real_cuts', type: 'jsonb', nullable: true }) plannedWagonRealCuts?: string[] | null; + /** + * Per-station loading/unloading time windows, clicked by yard operators: + * `{ [yardId]: { loading?: {...}, unloading?: {...} } }`. Booking load/unload + * is gated on the matching window having been STARTED at that yard; end is + * informational (elapsed time reporting). ISO strings, editable after the fact. + */ + @Column({ name: 'station_work_logs', type: 'jsonb', nullable: true }) + stationWorkLogs?: Record | null; + /** OPEN = accepting/holding bookings; FULL = train filled; CLOSED = manually closed. Orthogonal to `status`. */ @Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' }) bookingWindowStatus!: string; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 1c6b66ea8..9051bb816 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -195,6 +195,20 @@ describe('BookingBatchService — PAID reconcile', () => { expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledTimes(2); }); + it('ensurePaidBookingAllocated never resurrects a CANCELLED booking that is still paymentStatus PAID', async () => { + dataSource.getRepository().findOne.mockResolvedValue({ + ...paidBooking, + status: 'CANCELLED', + trainScheduleId: null, + } as unknown as Booking); + + await service.ensurePaidBookingAllocated(bookingId); + + expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled(); + expect(trainSchedulingService.tryAutoWagonAllocation).not.toHaveBeenCalled(); + expect(dataSource.getRepository().update).not.toHaveBeenCalled(); + }); + it('ensurePaidBookingAllocated holds a wagon-short booking out of the train', async () => { trainSchedulingService.previewPaidBookingWagonShortage.mockResolvedValue({ wagonTypeCodes: 'NW6', @@ -227,6 +241,45 @@ describe('BookingBatchService — PAID reconcile', () => { ).not.toHaveBeenCalled(); }); + it('ensurePaidBookingAllocated never re-places a MANUAL_ONLY booking (removed from a train by staff)', async () => { + dataSource.getRepository().findOne.mockResolvedValue({ + ...paidBooking, + trainScheduleId: null, + schedulingStatus: 'MANUAL_ONLY', + } as unknown as Booking); + + await service.ensurePaidBookingAllocated(bookingId); + + expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled(); + expect(trainSchedulingService.tryAutoWagonAllocation).not.toHaveBeenCalled(); + expect(dataSource.getRepository().update).not.toHaveBeenCalled(); + }); + + it('ensurePaidBookingAllocated skips a MANUAL_ONLY booking even when still pinned to a schedule', async () => { + dataSource.getRepository().findOne.mockResolvedValue({ + ...paidBooking, + schedulingStatus: 'MANUAL_ONLY', + } as unknown as Booking); + + await service.ensurePaidBookingAllocated(bookingId); + + expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled(); + expect(trainSchedulingService.tryAutoWagonAllocation).not.toHaveBeenCalled(); + }); + + it('reconcilePaidUnlinked leaves MANUAL_ONLY bookings alone', async () => { + bookingsRepository.findPaidUnlinkedForSchedule.mockResolvedValue([ + { ...paidBooking, schedulingStatus: 'MANUAL_ONLY' }, + ]); + + await service.reconcilePaidUnlinked(scheduleId); + + expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled(); + expect( + trainSchedulingService.previewPaidBookingWagonShortage, + ).not.toHaveBeenCalled(); + }); + it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => { const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(0); const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined); @@ -246,6 +299,71 @@ describe('BookingBatchService — PAID reconcile', () => { expect(reconcileOrder).toBeLessThan(wagonOrder); }); + describe('expire — consolidated pair, one side paid', () => { + const pairBooking = (id: string, partnerId: string, paid: boolean): Booking => + ({ + id, + reference: id, + status: paid ? 'PAID' : 'SELECTED_FOR_BATCH', + paymentStatus: paid ? 'PAID' : 'PENDING', + consolidationPartnerId: partnerId, + trainScheduleId: null, + bookingContainers: [], + }) as unknown as Booking; + + let emit: jest.Mock; + let unpaid: Booking; + let paid: Booking; + + beforeEach(() => { + unpaid = pairBooking('unpaid-1', 'paid-1', false); + paid = pairBooking('paid-1', 'unpaid-1', true); + emit = jest.fn(); + (service as unknown as { eventEmitter: { emit: jest.Mock } }).eventEmitter = { emit }; + (bookingsRepository as unknown as { clearConsolidationPair: jest.Mock }).clearConsolidationPair = + jest.fn().mockResolvedValue(undefined); + dataSource.getRepository().findOne.mockImplementation( + async ({ where }: { where: { id: string } }) => + where.id === 'paid-1' ? paid : unpaid, + ); + }); + + it('expires the unpaid side fee-free and cancels the PAID partner via partnerLapsed', async () => { + await (service as unknown as { expire(b: Booking): Promise }).expire(unpaid); + + // Paid partner is NOT rescued onto a train — the listener cancels it with the fee. + expect(emit).toHaveBeenCalledWith('booking.consolidation.partnerLapsed', { + paidBookingId: 'paid-1', + }); + expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled(); + // The unpaid side itself just expires. + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'unpaid-1', + expect.objectContaining({ status: 'EXPIRED' }), + ); + expect(notifier.expired).toHaveBeenCalledTimes(1); + }); + + it('wrong side called first: PAID booking is cancelled via partnerLapsed, never rescued', async () => { + await (service as unknown as { expire(b: Booking): Promise }).expire(paid); + + expect(emit).toHaveBeenCalledWith('booking.consolidation.partnerLapsed', { + paidBookingId: 'paid-1', + }); + // The unpaid partner expired fee-free… + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'unpaid-1', + expect.objectContaining({ status: 'EXPIRED' }), + ); + // …and the paid side was neither expired nor allocated here. + expect(bookingsRepository.update).not.toHaveBeenCalledWith( + 'paid-1', + expect.objectContaining({ status: 'EXPIRED' }), + ); + expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled(); + }); + }); + describe('extendPaymentPhaseForTopUp', () => { const schedRepo = () => dataSource.getRepository(); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 3469413ca..d787f737d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -76,7 +76,7 @@ import { bookingCargoTons, bulkItemsFitFor, bulkItemWagonsRequired, - bulkTonsPerWagon, + bulkTonsPerWagonFor, bookingGrossWeightTons, deriveTrainCapacityFromLocomotive, sizePartialOfferWagons, @@ -571,6 +571,17 @@ export class BookingBatchService implements OnModuleInit { relations: { company: true }, }); if (!booking) return; + // A dead booking keeps payment_status = 'PAID' (it was paid before it died), + // so every rescue path below would happily re-place and re-allocate it — + // that is how a cancelled consolidation-lapse booking came back onto its + // train 30s after being cancelled. Never resurrect a dead booking. + if (["CANCELLED", "EXPIRED", "REJECTED", "COMPLETED"].includes(booking.status)) + return; + // Staff removed this booking from a train (dispatch left-behind / manual + // unassign) — every auto-allocation rescue below must leave it alone, or + // the next document review / sweep silently retakes the space it was + // pulled from. Only a manual staff assignment may re-place it. + if (booking.schedulingStatus === "MANUAL_ONLY") return; if (!booking.trainScheduleId) { // A paid booking with no train is money taken and nothing boarding. The // hold was expired before the payment landed (webhook lag beat the @@ -1547,6 +1558,8 @@ export class BookingBatchService implements OnModuleInit { for (const booking of unlinked) { // Held on purpose (paid, no wagon free) — the cron must not undo it. if (booking.schedulingStatus === "WAITING_FOR_WAGON") continue; + // Removed from a train by staff — manual re-assignment only. + if (booking.schedulingStatus === "MANUAL_ONLY") continue; if (await this.holdIfWagonShort(scheduleId, booking)) continue; await this.allocate(scheduleId, booking, "paid"); this.logger.log( @@ -2878,7 +2891,8 @@ export class BookingBatchService implements OnModuleInit { .map((o) => ({ ...o, free: c.stock?.availableFor([o.wagonTypeId], leg) ?? 0, - takePerWagon: bulkTonsPerWagon( + takePerWagon: bulkTonsPerWagonFor( + booking, booking.cargoType, o.wagonTypeId, o.dims.capacityTons, @@ -3991,9 +4005,10 @@ export class BookingBatchService implements OnModuleInit { * taken, so it boards, even when the webhook arrived after the deadline or the * settle read a stale row. It allocates onto the train it was selected for; if * the wagon planner then finds no physical wagon, the booking stays linked and - * staff assign wagons manually. Consolidated bookings are exempt from the - * rescue: the shared wagon is both-or-neither, and settleReserved owns that - * pair decision. + * staff assign wagons manually. EXCEPTION — a consolidated booking whose + * partner lapsed unpaid is NOT rescued: its odd 20ft cannot board without the + * partner, so the paid side is cancelled with the cancellation fee (the + * partnerLapsed listener in BookingWagonCancellationService). */ private async expire( booking: Booking, @@ -4001,10 +4016,11 @@ export class BookingBatchService implements OnModuleInit { ): Promise { // Consolidated pair: break the link FIRST, then settle each side singly. // - neither paid → both expire, no fee. - // - one side paid → the paid half keeps the whole wagon (rescued by the - // paid guard below at no extra cost); the lapsed half expires and owes - // the cancellation fee (the 'partnerLapsed' event opens the fee invoice - // in BookingWagonCancellationService). + // - one side paid → BOTH die: the unpaid half expires fee-free (fees only + // apply to paid bookings); the paid half cannot board alone, so the + // 'partnerLapsed' event cancels it with the cancellation fee on ceil of + // its wagons (BookingWagonCancellationService) — paid freight kept as + // rebooking credit for GL staff. // - both paid → nothing to expire; the paid guard rescues. if (booking.consolidationPartnerId) { const partnerId = booking.consolidationPartnerId; @@ -4029,19 +4045,22 @@ export class BookingBatchService implements OnModuleInit { if (partnerRow) partnerRow.consolidationPartnerId = null; if (selfPaid && !partnerPaid) { - // Wrong side called first: the lapsed partner is the one that expires - // (with its fee); this paid booking falls through to the rescue below. + // Wrong side called first: the unpaid partner expires fee-free; this + // PAID booking cannot board without it, so the listener cancels it + // with the cancellation fee — never rescued. if (partnerRow && !["EXPIRED", "CANCELLED"].includes(partnerRow.status)) { - this.eventEmitter?.emit("booking.consolidation.partnerLapsed", { - expiredBookingId: partnerRow.id, - }); await this.expire(partnerRow, reason); } - } else if (!selfPaid && partnerPaid) { this.eventEmitter?.emit("booking.consolidation.partnerLapsed", { - expiredBookingId: booking.id, + paidBookingId: booking.id, + }); + return; + } else if (!selfPaid && partnerPaid) { + // This unpaid side expires below, fee-free; the PAID partner cannot + // board alone, so the listener cancels it with the cancellation fee. + this.eventEmitter?.emit("booking.consolidation.partnerLapsed", { + paidBookingId: partnerId, }); - // fall through: this side expires below; the paid partner is untouched. } else if (!selfPaid && !partnerPaid) { if (partnerRow && !["EXPIRED", "CANCELLED"].includes(partnerRow.status)) { await this.expire(partnerRow, reason); @@ -4335,8 +4354,8 @@ export class BookingBatchService implements OnModuleInit { swept.add(booking.id); // Consolidated pair: the partner may sit outside this route-day's result // set (different yards/day/status), so cascade explicitly — an unpaid - // partner expires with this booking; a PAID partner keeps the whole - // wagon and this booking owes the cancellation fee (partnerLapsed). + // partner expires with this booking, fee-free; a PAID partner cannot + // board alone, so partnerLapsed cancels it with the cancellation fee. if (booking.consolidationPartnerId) { const partner = await this.dataSource.getRepository(Booking).findOne({ where: { id: booking.consolidationPartnerId }, @@ -4352,7 +4371,7 @@ export class BookingBatchService implements OnModuleInit { partner.paymentStatus === "PAID" || partner.status === "PAID"; if (partnerPaid) { this.eventEmitter?.emit("booking.consolidation.partnerLapsed", { - expiredBookingId: booking.id, + paidBookingId: partner.id, }); } else if (!["EXPIRED", "CANCELLED"].includes(partner.status)) { swept.add(partner.id); @@ -4711,7 +4730,8 @@ export class BookingBatchService implements OnModuleInit { const cargoTons = bookingCargoTons(booking); // PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T // wagon), so divide by the cap where one is configured for this type. - const tonsPerWagon = bulkTonsPerWagon( + const tonsPerWagon = bulkTonsPerWagonFor( + booking, booking.cargoType, booking.cargoType?.wagonTypes?.[0]?.id, capacityTons, @@ -4787,7 +4807,8 @@ export class BookingBatchService implements OnModuleInit { const wagonTypeId = o.wagonTypeId as string; // Each type sized on its OWN per-wagon tonnage cap, not just its rating // — a type capped lower swallows less per wagon. - const tonsPerWagon = bulkTonsPerWagon( + const tonsPerWagon = bulkTonsPerWagonFor( + booking, booking.cargoType, wagonTypeId, o.dims.capacityTons, @@ -5199,7 +5220,8 @@ export class BookingBatchService implements OnModuleInit { .map((o) => ({ ...o, free: stock.availableFor([o.wagonTypeId], leg), - takePerWagon: bulkTonsPerWagon( + takePerWagon: bulkTonsPerWagonFor( + booking, booking.cargoType, o.wagonTypeId, o.dims.capacityTons, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index 56b28fb7f..c7256d82d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -18,7 +18,11 @@ import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon- import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { Yard } from '../rule-engine/entities/yard.entity'; import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; -import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { + StationWorkLog, + StationWorkPhaseLog, + TrainSchedule, +} from '../train-schedules/entities/train-schedule.entity'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; import { Wagon } from '../wagons/entities/wagon.entity'; @@ -77,6 +81,7 @@ export class BookingJourneyService { ); } await this.assertTrainAtYard(schedule, booking.originYardId, 'origin'); + this.assertStationWorkStarted(schedule, booking.originYardId, 'loading'); await this.assertYardCanHandleCargo(booking, booking.originYardId, 'origin'); // Export cargo must be in the warehouse with a GRN before it can be loaded, // however it arrived and whatever it is allocated to. @@ -166,6 +171,7 @@ export class BookingJourneyService { ); } await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination'); + this.assertStationWorkStarted(schedule, booking.destinationYardId, 'unloading'); await this.assertYardCanHandleCargo(booking, booking.destinationYardId, 'destination'); // Intercity has no clearance/delivery tail — unloading completes it. Import/ @@ -294,6 +300,9 @@ export class BookingJourneyService { // dispatch — assertTrainAtYard allows origin loading in that state, so // the UI position must agree or origin Load buttons grey out wrongly. trainAtYardId: latest?.yardId ?? schedule.originStationId, + // Per-yard loading/unloading time windows — the UI derives its + // start/end buttons and the load/unload gating from these. + stationWorkLogs: schedule.stationWorkLogs ?? {}, yards: [...byYard.values()], }; } @@ -414,6 +423,62 @@ export class BookingJourneyService { return rows.map((r) => r.id); } + /** + * Record a station's loading/unloading time-window click (or edit it — an + * explicit `at` on an already-set edge overwrites the timestamp under the + * same permission that set it). Rules: end needs start, start ≤ end, no + * future times. Stored as ISO strings in train_schedules.station_work_logs. + * ponytail: read-modify-write on the jsonb — two operators clicking the same + * schedule in the same instant can clobber one edge; move to jsonb_set if + * that ever bites. + */ + async recordStationWork( + scheduleId: string, + yardId: string, + phase: 'loading' | 'unloading', + edge: 'start' | 'end', + at?: string, + userId?: string | null, + ) { + const schedule = await this.getSchedule(scheduleId); + const when = at ? new Date(at) : new Date(); + if (Number.isNaN(when.getTime())) { + throw new BadRequestException('Invalid timestamp'); + } + if (when.getTime() > Date.now() + 60_000) { + throw new BadRequestException(`${phase} ${edge} time cannot be in the future`); + } + + const logs: Record = schedule.stationWorkLogs ?? {}; + const entry: StationWorkLog = logs[yardId] ?? {}; + const ph: StationWorkPhaseLog = entry[phase] ?? {}; + + if (edge === 'end') { + if (!ph.startedAt) { + throw new BadRequestException(`Start ${phase} at this station first`); + } + if (when.getTime() < new Date(ph.startedAt).getTime()) { + throw new BadRequestException(`${phase} end cannot be before its start`); + } + ph.endedAt = when.toISOString(); + ph.endedByUserId = userId ?? null; + } else { + if (ph.endedAt && when.getTime() > new Date(ph.endedAt).getTime()) { + throw new BadRequestException(`${phase} start cannot be after its end`); + } + ph.startedAt = when.toISOString(); + ph.startedByUserId = userId ?? null; + } + + entry[phase] = ph; + logs[yardId] = entry; + await this.dataSource + .getRepository(TrainSchedule) + .update(scheduleId, { stationWorkLogs: logs }); + + return { scheduleId, yardId, phase, ...ph }; + } + // ---- helpers --------------------------------------------------------------- private async getSchedule(scheduleId: string): Promise { @@ -481,6 +546,27 @@ export class BookingJourneyService { } } + /** + * Loading/unloading a booking is only allowed inside a started work window + * at that yard — the operator must click "Start loading"/"Start unloading" + * (recordStationWork) before touching cargo. The window's END is not checked: + * a straggler booking can still be confirmed after the end click, and the + * operator can push the end time later (it's editable) if that matters. + * Lives here (not the controller) so the checkpoint-driven autoUnloadAtYard + * path is gated too — the user wants unloading fully manual. + */ + private assertStationWorkStarted( + schedule: TrainSchedule, + yardId: string, + phase: 'loading' | 'unloading', + ): void { + if (!schedule.stationWorkLogs?.[yardId]?.[phase]?.startedAt) { + throw new BadRequestException( + `Start ${phase} at this station first — the ${phase} time window has not been started`, + ); + } + } + /** * The train is "at" a yard when the latest recorded checkpoint is that yard, * or — for a booking boarding at the train's own origin — when the train has diff --git a/apps/edr-freight-api/src/modules/train-scheduling/checkpoint-handling-times.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/checkpoint-handling-times.spec.ts new file mode 100644 index 000000000..d42700b81 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/checkpoint-handling-times.spec.ts @@ -0,0 +1,69 @@ +import { TrainSchedulingService } from './services/train-scheduling.service'; +import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; + +/** + * Loading and unloading stamps decide two published figures — total handling + * (unloading start to loading end) and the other activity left over from the + * stay. A crossed or future pair would publish negative hours, so the guard is + * the thing worth pinning down. + */ +const svc = Object.create(TrainSchedulingService.prototype) as { + handlingPatch: ( + dto: Record, + existing?: TrainCheckpointEvent | null, + ) => Record; +}; + +const iso = (h: number): string => new Date(Date.UTC(2026, 6, 3, h)).toISOString(); +const stop = (fields: Partial) => fields as TrainCheckpointEvent; + +describe('checkpoint handling times', () => { + it('takes a sane handling window', () => { + const patch = svc.handlingPatch({ + unloadingStartedAt: iso(4), + loadingCompletedAt: iso(9), + }); + + expect(patch.unloadingStartedAt).toEqual(new Date(iso(4))); + expect(patch.loadingCompletedAt).toEqual(new Date(iso(9))); + }); + + it('rejects loading finishing before unloading started', () => { + expect(() => + svc.handlingPatch({ unloadingStartedAt: iso(9), loadingCompletedAt: iso(4) }), + ).toThrow('Loading cannot finish before unloading started'); + }); + + it('rejects a window that runs backwards', () => { + expect(() => + svc.handlingPatch({ loadingStartedAt: iso(9), loadingCompletedAt: iso(8) }), + ).toThrow('Loading cannot finish before it started'); + }); + + it('rejects a stamp in the future', () => { + const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + expect(() => svc.handlingPatch({ unloadingStartedAt: tomorrow })).toThrow( + 'Unloading start cannot be in the future', + ); + }); + + // A body that moves one end of a window is still checked against the end + // already stored, or a two-step edit could walk the stop into a crossed pair. + it('checks a one-sided edit against the stored stop', () => { + expect(() => + svc.handlingPatch( + { loadingCompletedAt: iso(4) }, + stop({ unloadingStartedAt: new Date(iso(9)) }), + ), + ).toThrow('Loading cannot finish before unloading started'); + }); + + it('clears a stamp on null and leaves an untouched one alone', () => { + const patch = svc.handlingPatch( + { unloadingStartedAt: null }, + stop({ unloadingStartedAt: new Date(iso(4)), loadingCompletedAt: new Date(iso(9)) }), + ); + + expect(patch).toEqual({ unloadingStartedAt: null }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index e9a747784..efa08a927 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -18,14 +18,19 @@ import { TrainSchedulingCreate, TrainSchedulingEditTrainNumber, TrainSchedulingLoad, + TrainSchedulingLoadingEnd, + TrainSchedulingLoadingStart, TrainSchedulingReschedule, TrainSchedulingUnload, + TrainSchedulingUnloadingEnd, + TrainSchedulingUnloadingStart, TrainSchedulingRulesManage, TrainSchedulingUpdate, TrainSchedulingView, } from "../../../common/booking-guards"; import { FREIGHT_PERMS } from "../../../seed/freight-permissions.registry"; import { AcceptIntercityBookingsDto } from "../dto/accept-intercity-bookings.dto"; +import { StationWorkDto } from "../dto/station-work.dto"; import { AssignBookingsDto } from "../dto/assign-bookings.dto"; import { AssignUnassignedBookingDto } from "../dto/assign-unassigned-booking.dto"; import { SwitchGovernmentBookingDto } from "../dto/switch-government-booking.dto"; @@ -566,8 +571,9 @@ export class TrainSchedulingController { dispatchSchedule( @Param("id", ParseUUIDPipe) id: string, @Body() dto: DispatchScheduleDto, + @CurrentUser() user: AuthUserPayload, ) { - return this.trainSchedulingService.dispatchSchedule(id, dto); + return this.trainSchedulingService.dispatchSchedule(id, dto, resolveAuthUserId(user)); } @Get("intercity/bookings") @@ -613,6 +619,68 @@ export class TrainSchedulingController { return this.bookingJourneyService.listYardWork(id); } + @Post("schedules/:id/stations/:yardId/loading/start") + @TrainSchedulingLoadingStart() + @ApiOperation({ + summary: + "Start (or correct, via `at`) this station's loading time window — required before bookings can be loaded there", + }) + startStationLoading( + @Param("id", ParseUUIDPipe) id: string, + @Param("yardId", ParseUUIDPipe) yardId: string, + @Body() dto: StationWorkDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.bookingJourneyService.recordStationWork( + id, yardId, "loading", "start", dto.at, resolveAuthUserId(user), + ); + } + + @Post("schedules/:id/stations/:yardId/loading/end") + @TrainSchedulingLoadingEnd() + @ApiOperation({ summary: "End (or correct, via `at`) this station's loading time window" }) + endStationLoading( + @Param("id", ParseUUIDPipe) id: string, + @Param("yardId", ParseUUIDPipe) yardId: string, + @Body() dto: StationWorkDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.bookingJourneyService.recordStationWork( + id, yardId, "loading", "end", dto.at, resolveAuthUserId(user), + ); + } + + @Post("schedules/:id/stations/:yardId/unloading/start") + @TrainSchedulingUnloadingStart() + @ApiOperation({ + summary: + "Start (or correct, via `at`) this station's unloading time window — required before bookings can be unloaded there", + }) + startStationUnloading( + @Param("id", ParseUUIDPipe) id: string, + @Param("yardId", ParseUUIDPipe) yardId: string, + @Body() dto: StationWorkDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.bookingJourneyService.recordStationWork( + id, yardId, "unloading", "start", dto.at, resolveAuthUserId(user), + ); + } + + @Post("schedules/:id/stations/:yardId/unloading/end") + @TrainSchedulingUnloadingEnd() + @ApiOperation({ summary: "End (or correct, via `at`) this station's unloading time window" }) + endStationUnloading( + @Param("id", ParseUUIDPipe) id: string, + @Param("yardId", ParseUUIDPipe) yardId: string, + @Body() dto: StationWorkDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.bookingJourneyService.recordStationWork( + id, yardId, "unloading", "end", dto.at, resolveAuthUserId(user), + ); + } + @Post("schedules/:id/bookings/:bookingId/load") @TrainSchedulingLoad() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts index 1495185f0..06c363bc4 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts @@ -1,11 +1,13 @@ import { ApiProperty } from '@nestjs/swagger'; import { TrainCheckpointKind } from '@edr/types'; import { + IsArray, IsEnum, IsInt, IsISO8601, IsOptional, IsString, + IsUUID, MaxLength, Min, } from 'class-validator'; @@ -35,6 +37,31 @@ export class RecordCheckpointDto { @IsISO8601() occurredAt?: string; + /** + * Station work during the stay this stop opens — what the OCC report calls + * loading and unloading time. All optional: a stop logged without them still + * records its staying time. + */ + @ApiProperty({ required: false, description: 'ISO timestamp; unloading start.' }) + @IsOptional() + @IsISO8601() + unloadingStartedAt?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsISO8601() + unloadingCompletedAt?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsISO8601() + loadingStartedAt?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsISO8601() + loadingCompletedAt?: string; + @ApiProperty({ required: false }) @IsOptional() @IsString() @@ -52,6 +79,27 @@ export class UpdateCheckpointDto { @IsISO8601() occurredAt?: string; + /** Null clears a mis-entered stamp; undefined leaves it as it is. */ + @ApiProperty({ required: false, nullable: true }) + @IsOptional() + @IsISO8601() + unloadingStartedAt?: string | null; + + @ApiProperty({ required: false, nullable: true }) + @IsOptional() + @IsISO8601() + unloadingCompletedAt?: string | null; + + @ApiProperty({ required: false, nullable: true }) + @IsOptional() + @IsISO8601() + loadingStartedAt?: string | null; + + @ApiProperty({ required: false, nullable: true }) + @IsOptional() + @IsISO8601() + loadingCompletedAt?: string | null; + @ApiProperty({ required: false, nullable: true }) @IsOptional() @IsString() @@ -67,4 +115,20 @@ export class DispatchScheduleDto { @IsOptional() @IsISO8601() actualDepartureAt?: string; + + /** + * Loading is a manual staff decision. When present, only these bookings are + * auto-loaded at the origin; every other unloaded origin boarder is left + * behind — deallocated from its wagon and returned to the booking pool. + * Absent (older clients) = load every origin boarder, the historic behavior. + */ + @ApiProperty({ + required: false, + description: + 'Origin-yard bookings confirmed loaded; the rest are unassigned back to the pool. Omit to auto-load all.', + }) + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + loadedBookingIds?: string[]; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/station-work.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/station-work.dto.ts new file mode 100644 index 000000000..6100b037a --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/station-work.dto.ts @@ -0,0 +1,14 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsISO8601, IsOptional } from 'class-validator'; + +/** + * A station loading/unloading window click. `at` omitted = "now" (the button + * click); `at` given = record or correct the timestamp after the fact — same + * endpoint, same permission. + */ +export class StationWorkDto { + @ApiPropertyOptional({ description: 'ISO timestamp; omitted = now. Never in the future.' }) + @IsOptional() + @IsISO8601() + at?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-checkpoint-event.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-checkpoint-event.entity.ts index 5fa90a2e1..044551402 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-checkpoint-event.entity.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-checkpoint-event.entity.ts @@ -37,6 +37,28 @@ export class TrainCheckpointEvent extends BaseEntity { @Column({ name: 'occurred_at', type: 'timestamptz' }) occurredAt!: Date; + /** + * Station work, on the stop it happened at. Null where nobody logged it — + * an unlogged stop still reports its staying time, with the handling split + * empty rather than zero. + * + * The stay these belong to opens with THIS arrival and closes with the next + * departure, which is a different schedule when the train turns around. That + * is why they ride the arrival row: it is the row the staying-time report + * builds a stop from. + */ + @Column({ name: 'unloading_started_at', type: 'timestamptz', nullable: true }) + unloadingStartedAt?: Date | null; + + @Column({ name: 'unloading_completed_at', type: 'timestamptz', nullable: true }) + unloadingCompletedAt?: Date | null; + + @Column({ name: 'loading_started_at', type: 'timestamptz', nullable: true }) + loadingStartedAt?: Date | null; + + @Column({ name: 'loading_completed_at', type: 'timestamptz', nullable: true }) + loadingCompletedAt?: Date | null; + @Column({ name: 'note', type: 'text', nullable: true }) note?: string | null; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts index 15cf4f592..57bbb2c58 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts @@ -170,7 +170,7 @@ describe('TrainSchedulingService', () => { wagonAllocationContainerItemsRepository as never, wagonAllocationBulkLoadsRepository as never, trainCheckpointEventsRepository as never, - {} as never, // trainCompositionRemovalLogRepository + { create: jest.fn() } as never, // trainCompositionRemovalLogRepository { autoUnloadArrivedBookings: jest.fn(), autoUnloadExportAtDjibouti: jest.fn(), @@ -182,7 +182,7 @@ describe('TrainSchedulingService', () => { { autoArriveAtFinalYard: jest.fn().mockResolvedValue([]), } as never, // bookingJourneyService - { dispatched: jest.fn(), arrived: jest.fn() } as never, // bookingNotifier + { dispatched: jest.fn(), arrived: jest.fn(), removedFromTrain: jest.fn() } as never, // bookingNotifier { getLogoImageUrl: jest.fn().mockResolvedValue(null) } as never, // logoSettings ); @@ -1640,6 +1640,85 @@ describe('TrainSchedulingService', () => { }); }); + describe('unassignBooking — MANUAL_ONLY status', () => { + const scheduleId = 'sched-rm-1'; + const removed = makeBooking('bk-rm', 'BKG-RM', 100, 5, '20FT', 5, undefined, undefined, undefined, { + status: 'PAID', + wagonsRequired: 5, + }); + + const graph = { + id: scheduleId, + status: 'DRAFT', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + trainSetId: 'ts-rm', + trainSet: { + id: 'ts-rm', + locomotive, + trainId: null, + wagons: [{ id: 'tsw-rm-1', allocations: [{ id: 'alloc-rm-1', bookingId: 'bk-rm' }] }], + }, + scheduleBookings: [{ bookingId: 'bk-rm' }], + }; + + const txManager = { + getRepository: jest.fn(() => ({ + find: jest.fn().mockResolvedValue([]), + findOne: jest.fn().mockResolvedValue(null), + update: jest.fn().mockResolvedValue(undefined), + delete: jest.fn().mockResolvedValue(undefined), + save: jest.fn().mockResolvedValue(undefined), + create: jest.fn((x: unknown) => x), + })), + }; + + beforeEach(() => { + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(graph); + bookingsRepository.findById = jest.fn().mockResolvedValue(removed); + bookingsRepository.updateSchedulingFields.mockResolvedValue(undefined); + dataSource.transaction.mockImplementation( + async (fn: (m: unknown) => Promise) => fn(txManager), + ); + jest + .spyOn( + service as never as { getTrainScheduleById: (id: string) => Promise }, + 'getTrainScheduleById' as never, + ) + .mockResolvedValue({ id: scheduleId } as never); + }); + + it('marks a staff-removed paid booking MANUAL_ONLY and fully detaches it', async () => { + await service.unassignBooking(scheduleId, 'bk-rm', 'user-1'); + + expect(bookingsRepository.updateSchedulingFields).toHaveBeenCalledWith( + 'bk-rm', + expect.objectContaining({ + schedulingStatus: 'MANUAL_ONLY', + trainScheduleId: null, + wagonsRequired: null, + }), + expect.anything(), + ); + expect(trainScheduleBookingsRepository.deleteByScheduleAndBooking).toHaveBeenCalledWith( + scheduleId, + 'bk-rm', + expect.anything(), + ); + expect(wagonAllocationContainerItemsRepository.deleteByAllocationIds).toHaveBeenCalledWith( + ['alloc-rm-1'], + expect.anything(), + ); + }); + + it('never marks ELIGIBLE — a removed booking must not rejoin the auto pool', async () => { + await service.unassignBooking(scheduleId, 'bk-rm', 'user-1'); + + const updates = bookingsRepository.updateSchedulingFields.mock.calls.map((c) => c[1]); + expect(updates.some((u) => u.schedulingStatus === 'ELIGIBLE')).toBe(false); + }); + }); + describe('updateCheckpoint — leg time correction', () => { const t = (h: number) => new Date(Date.UTC(2026, 0, 1, h)); const schedule = { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 5ac6ed0bb..9cec10ff7 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -156,7 +156,7 @@ import { bookingCargoTons, bulkItemsFitFor, bulkItemWagonsRequired, - bulkTonsPerWagon, + bulkTonsPerWagonFor, consistViolations, deriveTrainCapacityFromLocomotive, combinedLocomotiveLimits, @@ -211,6 +211,16 @@ import { const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const; +/** The station-work stamps a stop can carry, with the label an error names. */ +const HANDLING_FIELDS = [ + ['unloadingStartedAt', 'Unloading start'], + ['unloadingCompletedAt', 'Unloading completion'], + ['loadingStartedAt', 'Loading start'], + ['loadingCompletedAt', 'Loading completion'], +] as const; + +type HandlingField = (typeof HANDLING_FIELDS)[number][0]; + /** Drops the keys a partial override left undefined, so `...` merges keep the base value. */ function pickDefined(source: T): Partial { return Object.fromEntries( @@ -2360,16 +2370,22 @@ export class TrainSchedulingService { if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); } - if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { - throw new BadRequestException('Cannot unassign from a finalized or dispatched schedule'); - } - const link = schedule.scheduleBookings?.find((sb) => sb.bookingId === bookingId); if (!link) { throw new NotFoundException(`Booking ${bookingId} is not assigned to this schedule`); } const booking = await this.bookingsRepository.findById(bookingId); + // A dispatched train may still shed a booking staff left behind at its + // boarding yard (dispatch dialog / log-pass "leave") — but never one whose + // cargo is actually on the train. + const leftBehindWhileDispatched = + schedule.status === 'DISPATCHED' && + !booking?.loadedAt && + booking?.status !== 'IN_TRANSIT'; + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status) && !leftBehindWhileDispatched) { + throw new BadRequestException('Cannot unassign from a finalized or dispatched schedule'); + } if (booking?.isGovernment) { throw new BadRequestException( 'Government bookings cannot be removed from a train. They can only be switched onto another allocation.', @@ -2399,7 +2415,12 @@ export class TrainSchedulingService { ); const booking = await this.bookingsRepository.findById(bookingId); - const schedulingStatus = this.resolvePostUnassignStatus(booking); + // Removed from a train by staff → MANUAL_ONLY: the paid booking must not + // be auto re-placed by any allocation sweep (it would retake the space it + // was just pulled from). Staff re-assign it manually; assign resets the + // status to SCHEDULED. Schedule *cancellation* keeps the old behaviour + // (resolvePostUnassignStatus) — there the train died, not the booking. + const schedulingStatus = SchedulingStatus.ManualOnly; // Clear the schedule pointer too: unassign fully detaches the booking from // this train. Leaving trainScheduleId set glued the booking to a schedule // that may then be dispatched/cancelled/deleted, orphaning it — the @@ -2437,6 +2458,21 @@ export class TrainSchedulingService { for (const slot of survivingSlots) { const slotAllocations = slot.allocations ?? []; if (slotAllocations.length === 0) { + // A dispatched train pinned its wagons (ASSIGNED + schedule id) at + // departure — freeing the slot must also free the physical wagon, or + // the checkpoint position-fix keeps dragging it along the corridor. + if (schedule.status === 'DISPATCHED' && slot.physicalWagonId) { + const wagon = await manager + .getRepository(Wagon) + .findOne({ where: { id: slot.physicalWagonId } }); + if (wagon && wagon.currentTrainScheduleId === scheduleId) { + await manager.getRepository(Wagon).update(wagon.id, { + currentTrainScheduleId: null, + trainSetWagonId: null, + status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, + }); + } + } await manager.getRepository(TrainSetWagon).delete(slot.id); continue; } @@ -2466,8 +2502,10 @@ export class TrainSchedulingService { // Freed wagons may un-full the train — re-derive the window status (this // also revives a DONE window pre-departure so the freed space is bookable - // again for import/export). - await this.bookingBatchService?.refreshWindowStatus(scheduleId); + // again for import/export). A dispatched train's window stays CLOSED. + if (schedule.status !== 'DISPATCHED') { + await this.bookingBatchService?.refreshWindowStatus(scheduleId); + } await this.trainCompositionRemovalLogRepository.create({ scheduleId, @@ -2832,14 +2870,54 @@ export class TrainSchedulingService { return this.getTrainScheduleById(scheduleId); } - async dispatchSchedule(scheduleId: string, dto: DispatchScheduleDto = {}) { - const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + async dispatchSchedule(scheduleId: string, dto: DispatchScheduleDto = {}, userId?: string) { + let schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); } if (schedule.status !== TrainScheduleStatusEnum.Scheduled) { throw new BadRequestException('Only SCHEDULED trains can be dispatched'); } + // Loading is a manual staff decision: when the dispatch dialog sends the + // checked list, every other unloaded origin boarder is left behind — + // deallocated from its wagon and returned to the booking pool — so the + // origin auto-load below only ever touches confirmed cargo. Government + // bookings cannot be unassigned and keep the historic auto-load. + if (dto.loadedBookingIds) { + const keep = new Set(dto.loadedBookingIds); + const candidates = await this.unloadedOriginBoarderIds(scheduleId, schedule.originStationId); + const leftBehind = candidates.filter((id) => !keep.has(id)); + for (const bookingId of leftBehind) { + await this.unassignBooking(scheduleId, bookingId, userId); + } + if (leftBehind.length) { + // Unassign deleted allocations and slots — reload the graph dispatch works on. + const reloaded = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!reloaded) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + schedule = reloaded; + } + } + // Loading is tracked per station: dispatching with cargo still to board at + // the origin marks it loaded (checklist + auto-load below), so the origin's + // loading time window must have been started first — same gate the + // per-booking load endpoint enforces. + const originBoarders = await this.unloadedOriginBoarderIds( + scheduleId, + schedule.originStationId, + ); + const boardersToLoad = dto.loadedBookingIds + ? originBoarders.filter((id) => new Set(dto.loadedBookingIds).has(id)) + : originBoarders; + if ( + boardersToLoad.length && + !schedule.stationWorkLogs?.[schedule.originStationId]?.loading?.startedAt + ) { + throw new BadRequestException( + 'Start loading at the origin station before dispatching with cargo to load', + ); + } // Staff may record the departure after the fact — past is fine, future is not. const now = dto.actualDepartureAt ? new Date(dto.actualDepartureAt) : new Date(); this.assertNotFuture(now, 'Departure time'); @@ -3061,6 +3139,34 @@ export class TrainSchedulingService { }); } + /** + * Origin boarders the dispatch dialog decides over: unloaded (no journey + * load, no workspace LOADED flag), boardable, non-government. Boardable is + * PAID — or FULLY_EXECUTED for shipping-line bookings, which never prepay + * (their charge sits on the credit ledger) yet ride from accept. + */ + private async unloadedOriginBoarderIds( + scheduleId: string, + originYardId: string, + ): Promise { + const rows: Array<{ id: string }> = await this.dataSource.query( + `SELECT b.id + FROM freight.bookings b + JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id + WHERE tsb.train_schedule_id = $1 + AND tsb.deleted_at IS NULL + AND b.deleted_at IS NULL + AND b.origin_yard_id = $2 + AND b.loaded_at IS NULL + AND COALESCE(tsb.loading_status, 'UNLOADED') <> 'LOADED' + AND b.is_government = false + AND (b.status = 'PAID' + OR (b.shipping_line_company_id IS NOT NULL AND b.status = 'FULLY_EXECUTED'))`, + [scheduleId, originYardId], + ); + return rows.map((r) => r.id); + } + async getImportDjiboutiOperation(scheduleId: string) { const schedule = await this.getDjiboutiGatepassSchedule(scheduleId); const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); @@ -4364,6 +4470,9 @@ export class TrainSchedulingService { origin: stations[0]?.label ?? null, destination: stations[stations.length - 1]?.label ?? null, stations, + // Per-yard loading/unloading time windows for the track page's + // start/end buttons and elapsed-time display. + stationWorkLogs: schedule.stationWorkLogs ?? {}, currentSequenceNo, checkpoints: events.map((e) => ({ id: e.id, @@ -4372,11 +4481,65 @@ export class TrainSchedulingService { label: e.yard?.label ?? e.yard?.code ?? null, kind: e.kind, occurredAt: e.occurredAt.toISOString(), + unloadingStartedAt: e.unloadingStartedAt?.toISOString() ?? null, + unloadingCompletedAt: e.unloadingCompletedAt?.toISOString() ?? null, + loadingStartedAt: e.loadingStartedAt?.toISOString() ?? null, + loadingCompletedAt: e.loadingCompletedAt?.toISOString() ?? null, note: e.note ?? null, })), }; } + /** + * The station-work stamps off a record/update body, validated as two windows. + * + * Staff enter these after the fact, so the past is allowed and the future is + * not — the same rule the checkpoint's own time follows. Neither window may + * run backwards, and loading may not finish before unloading began: the OCC + * figure is `loading end − unloading start`, and a crossed pair would publish + * negative handling and negative other activity. + * + * `undefined` leaves a stamp untouched; `null` clears a mis-entered one. + */ + private handlingPatch( + dto: Partial>, + existing?: TrainCheckpointEvent | null, + ): Partial> { + const patch: Partial> = {}; + for (const [field, label] of HANDLING_FIELDS) { + const raw = dto[field]; + if (raw === undefined) continue; + if (raw === null) { + patch[field] = null; + continue; + } + const at = new Date(raw); + this.assertNotFuture(at, label); + patch[field] = at; + } + if (!Object.keys(patch).length) return patch; + + // The stop as it will stand after the patch — a body that moves only one + // end of a window is still checked against the end already stored. + const merged = (field: HandlingField): Date | null => + field in patch ? (patch[field] ?? null) : (existing?.[field] ?? null); + const inOrder = (from: HandlingField, to: HandlingField, message: string): void => { + const start = merged(from); + const end = merged(to); + if (start && end && end.getTime() < start.getTime()) { + throw new BadRequestException(message); + } + }; + inOrder('unloadingStartedAt', 'unloadingCompletedAt', 'Unloading cannot finish before it started'); + inOrder('loadingStartedAt', 'loadingCompletedAt', 'Loading cannot finish before it started'); + inOrder( + 'unloadingStartedAt', + 'loadingCompletedAt', + 'Loading cannot finish before unloading started', + ); + return patch; + } + /** Log the train passing a station. Logging the destination station triggers arrival. */ async recordCheckpoint(scheduleId: string, dto: RecordCheckpointDto) { // Slim graph: checkpoint logging reads stops, locomotives, the built @@ -4411,12 +4574,14 @@ export class TrainSchedulingService { const [existing] = await this.trainCheckpointEventsRepository.findAll({ where: { trainScheduleId: scheduleId, sequenceNo: dto.sequenceNo }, }); + const handling = this.handlingPatch(dto, existing); if (existing) { await this.trainCheckpointEventsRepository.update(existing.id, { kind, occurredAt, note: dto.note ?? null, yardId: station.yardId, + ...handling, }); } else { await this.trainCheckpointEventsRepository.create({ @@ -4426,6 +4591,7 @@ export class TrainSchedulingService { kind, occurredAt, note: dto.note ?? null, + ...handling, }); } @@ -4686,6 +4852,7 @@ export class TrainSchedulingService { patch.occurredAt = occurredAt; } if (dto.note !== undefined) patch.note = dto.note; + Object.assign(patch, this.handlingPatch(dto, existing)); if (Object.keys(patch).length) { await this.trainCheckpointEventsRepository.update(existing.id, patch); } @@ -4780,6 +4947,23 @@ export class TrainSchedulingService { if (schedule.status !== TrainScheduleStatusEnum.Dispatched) { throw new BadRequestException('Only DISPATCHED trains can arrive'); } + // Arrival bulk-marks every booking destined for the final yard as arrived + // (autoArriveAtFinalYard) — unloading is tracked per station, so the + // destination's unloading time window must be started before that sweep + // may run. Skipped when nothing on the train alights at the final yard. + const alightsAtFinal = (schedule.scheduleBookings ?? []).some( + (sb) => + sb.booking?.destinationYardId === schedule.destinationStationId && + sb.booking?.status === 'IN_TRANSIT', + ); + if ( + alightsAtFinal && + !schedule.stationWorkLogs?.[schedule.destinationStationId]?.unloading?.startedAt + ) { + throw new BadRequestException( + 'Start unloading at the destination station before marking the train arrived', + ); + } // The arrival clock: the operator's entered time when arriving via the final // checkpoint (already order/future-checked there), else now. @@ -9290,6 +9474,8 @@ export class TrainSchedulingService { Booking, | 'freightType' | 'cargoTotalWeightVgm' + | 'bulkTotalWeightTons' + | 'bulkRequestedWagons' | 'wagonsRequired' | 'bookingContainers' | 'cargoType' @@ -9320,7 +9506,7 @@ export class TrainSchedulingService { const byLength = containerWagonsForLines(booking.bookingContainers ?? []); // PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T // wagon) — more wagons for the same cargo, so more tare to pull. - const tonsPerWagon = bulkTonsPerWagon(booking.cargoType, wagonTypeId, dims.capacityTons); + const tonsPerWagon = bulkTonsPerWagonFor(booking, booking.cargoType, wagonTypeId, dims.capacityTons); const byWeight = cargo > 0 && tonsPerWagon > 0 ? Math.ceil(cargo / tonsPerWagon) : 0; // Break-bulk (PER_ITEM): indivisible items occupy more wagons than raw // tonnage suggests — their tare must be pulled too (batch dimsFor parity). @@ -9798,10 +9984,17 @@ export class TrainSchedulingService { loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded, wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId), isGovernment: Boolean(sb.booking?.isGovernment), + // Shipping-line bookings never prepay (credit ledger) — the dispatch + // dialog needs this to know FULLY_EXECUTED means boardable for them. + shippingLineCompanyId: sb.booking?.shippingLineCompanyId ?? null, })) ?? [], // Ordered corridor stops (route milestones; falls back to the two // endpoints) — lets the UI draw per-segment occupancy and label legs. stops: this.mapScheduleStops(schedule), + // Per-yard loading/unloading time windows (start/end clicks) — the + // detail page shows the origin's loading window; dispatch requires it + // started when cargo boards there. + stationWorkLogs: schedule.stationWorkLogs ?? {}, // Gross ceiling the validator holds each leg to: the set's weakest // locomotive pull limit plus its overage tolerance. Booking weightTons // above are gross too, so the strip can sum them per leg against this. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts index ed476a6e1..05f386a4c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts @@ -5,6 +5,7 @@ import { bulkItemWagonsForAllowedTypes, bulkItemWagonsRequired, bulkTonsPerWagon, + bulkTonsPerWagonFor, bulkTonWagonsForAllowedTypes, bulkTonWagonsRequired, bulkWagonsForAllowedTypes, @@ -177,6 +178,19 @@ describe('train-capacity.util', () => { expect(bulkTonWagonsForAllowedTypes(bulk(200), cargoType, 70)).toBe(3); }); + it('NUMBER_OF_WAGONS: a requested count wins over the tonnage-derived one', () => { + const req = { ...bulk(100), bulkRequestedWagons: 40 }; + // 100T on 70T wagons is 2 by tonnage — the customer asked for 40. + expect(bulkTonWagonsRequired(req, null, 'nw5', 70)).toBe(40); + expect(bulkWagonsForAllowedTypes(req, { wagonTypes: [{ id: 'nw5', capacityTons: 70 }] }, 70)).toBe(40); + // Each wagon then carries the even share, not rated capacity. + expect(bulkTonsPerWagonFor(req, null, 'nw5', 70)).toBe(2.5); + // ceil(tons / evenShare) must land exactly on the requested count. + const awkward = { ...bulk(100), bulkRequestedWagons: 3 }; + const share = bulkTonsPerWagonFor(awkward, null, 'nw5', 70); + expect(Math.ceil(100 / share)).toBe(3); + }); + it('routes PER_ITEM and PER_TON through one call', () => { expect(bulkWagonsForAllowedTypes(bulk(200), sugar, 70)).toBe(4); // PER_ITEM still wins where an item count is present. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts index 63feb6e8c..8b980be11 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -114,6 +114,43 @@ export function bookingCargoTons(booking: { ); } +/** + * Customer-requested wagon count of a NUMBER_OF_WAGONS bulk booking; 0 when + * the booking carries none (every other cargo unit). The request was validated + * against wagon capacity at booking creation, so sizing code honours it + * verbatim instead of deriving a count from tonnage. + */ +export function requestedBulkWagons(booking: { + bulkRequestedWagons?: number | string | null; +}): number { + const n = Math.floor(num(booking.bulkRequestedWagons)); + return n > 0 ? n : 0; +} + +/** + * Booking-aware {@link bulkTonsPerWagon}: a NUMBER_OF_WAGONS booking fixed its + * wagon count, so each wagon carries tons ÷ requested (the even spread the + * customer asked for), never more. Rounded UP to 3 decimals so + * ceil(tons / perWagon) lands exactly on the requested count instead of one + * over on float error. Other bookings get the cargo-type figure unchanged. + */ +export function bulkTonsPerWagonFor( + booking: Parameters[0] & { + bulkRequestedWagons?: number | string | null; + }, + cargoType: ItemFitCargoType | undefined, + wagonTypeId: string | null | undefined, + capacityTons: number | string | null | undefined, +): number { + const base = bulkTonsPerWagon(cargoType, wagonTypeId, capacityTons); + const requested = requestedBulkWagons(booking); + if (!requested) return base; + const tons = bookingCargoTons(booking); + if (!(tons > 0)) return base; + const evenShare = Math.ceil((tons / requested) * 1000) / 1000; + return base > 0 ? Math.min(base, evenShare) : evenShare; +} + /** * Wagons a break-bulk (PER_ITEM) bulk booking needs. Items are indivisible, so * floor how many whole items fit one wagon, then ceil the wagon count: @@ -184,11 +221,16 @@ export function bulkTonsPerWagon( * usable per-wagon figure, so callers can fall back as before. */ export function bulkTonWagonsRequired( - booking: Parameters[0], + booking: Parameters[0] & { + bulkRequestedWagons?: number | string | null; + }, cargoType: ItemFitCargoType | undefined, wagonTypeId: string | null | undefined, capacityTons: number | string | null | undefined, ): number { + // NUMBER_OF_WAGONS: the customer fixed the count — honour it verbatim. + const requested = requestedBulkWagons(booking); + if (requested) return requested; const perWagon = bulkTonsPerWagon(cargoType, wagonTypeId, capacityTons); const tons = bookingCargoTons(booking); if (!(perWagon > 0) || !(tons > 0)) return 0; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts index 2df626a84..b4e87d398 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts @@ -7,7 +7,7 @@ import { bookingCargoTons, bulkItemsFitFor, bulkItemWagonsRequired, - bulkTonsPerWagon, + bulkTonsPerWagonFor, bulkTonWagonsRequired, consistViolations, } from '../train-capacity.util'; @@ -193,8 +193,11 @@ export function buildBulkWagonPlan( // pool with uncapped tonnage either: its wagons stop at the cap, so 200T needs // 4 wagons and pooling it at 70T would plan 3. Capped bookings are sized on // their own cap; only genuinely uncapped tonnage pools at rated capacity. + // A NUMBER_OF_WAGONS booking is "capped" at its even share (tons ÷ requested), + // so it plans exactly the requested count. const cappedTonSlotsByBooking = bookings.map((b, i) => - itemSlotsByBooking[i] > 0 || bulkTonsPerWagon(b.cargoType, wagonType.id, capacity) >= capacity + itemSlotsByBooking[i] > 0 || + bulkTonsPerWagonFor(b, b.cargoType, wagonType.id, capacity) >= capacity ? 0 : bulkTonWagonsRequired(b, b.cargoType, wagonType.id, capacity), ); @@ -330,6 +333,7 @@ function allocateBookingsToSlots( // bookings that column is an item COUNT, not tons. remainingWeightTons: roundTons(bookingCargoTons(booking)), cargoType: booking.cargoType, + booking, })); let bookingIndex = 0; @@ -343,10 +347,17 @@ function allocateBookingsToSlots( const booking = remaining[bookingIndex]; // A PER_TON loading cap (sugar 50T on a 70T wagon) binds the FILL as well // as the wagon count — the plan reserved a wagon per capped chunk, so - // pouring rated capacity into it would leave the last wagon empty. + // pouring rated capacity into it would leave the last wagon empty. A + // NUMBER_OF_WAGONS booking fills each wagon its even share (tons ÷ + // requested) for the same reason. const takeCap = Math.min( wagonRemaining, - bulkTonsPerWagon(booking.cargoType, slot.wagonTypeId, slot.capacityTons), + bulkTonsPerWagonFor( + booking.booking, + booking.cargoType, + slot.wagonTypeId, + slot.capacityTons, + ), ); const allocatedWeightTons = roundTons( Math.min(takeCap, booking.remainingWeightTons), diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts index 449033bfc..92e4b441d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -6,6 +6,7 @@ import { bookingCargoTons, bulkItemsFitFor, bulkTonsPerWagon, + bulkTonsPerWagonFor, bulkWagonsForAllowedTypes, } from './train-capacity.util'; import { @@ -179,7 +180,7 @@ const shortageFor = ( let seatable = 0; let usedWagons = 0; for (const { wt, free } of freeByType) { - const perWagon = bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons)); + const perWagon = bulkTonsPerWagonFor(booking, booking.cargoType, wt.id, Number(wt.capacityTons)); if (!(perWagon > 0) || free <= 0) continue; seatable += free * perWagon; usedWagons += free; @@ -188,7 +189,7 @@ const shortageFor = ( const bestPerWagon = Math.max( 1, ...candidates.map((wt) => - bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons)), + bulkTonsPerWagonFor(booking, booking.cargoType, wt.id, Number(wt.capacityTons)), ), ); return { @@ -583,7 +584,8 @@ export function planWagonsWithStock(params: { if (perItem ? remainingItems <= 0 : remainingWeight <= 0) break; const wagonType = candidates.find((wt) => wt.id === open.slot.wagonTypeId); if (!wagonType) continue; - const room = bulkTonsPerWagon( + const room = bulkTonsPerWagonFor( + booking, booking.cargoType, open.slot.wagonTypeId, Number(open.slot.capacityTons), @@ -640,7 +642,20 @@ export function planWagonsWithStock(params: { openedSlot.freeItems = itemBudgetOf(openedSlot) - takeItems; remainingItems -= takeItems; } else { - take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight)); + // NUMBER_OF_WAGONS: each wagon takes the even share (tons / requested), + // not the full per-wagon cap — the loop then opens exactly that count. + take = roundTons( + Math.min( + openedSlot.freeCapacityTons, + bulkTonsPerWagonFor( + booking, + booking.cargoType, + openedSlot.slot.wagonTypeId, + openedSlot.slot.capacityTons, + ), + remainingWeight, + ), + ); } addAllocation( openedSlot.slot, diff --git a/apps/edr-freight-api/src/modules/trains/dto/wagon-detach-request.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/wagon-detach-request.dto.ts new file mode 100644 index 000000000..afe219bfa --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/dto/wagon-detach-request.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; + +import { WagonDetachRequestAction } from '../entities/wagon-detach-request.entity'; + +export class CreateWagonDetachRequestDto { + @ApiProperty({ + enum: WagonDetachRequestAction, + description: 'What approval is being asked for: a plain detach, or detach + MAINTENANCE.', + }) + @IsEnum(WagonDetachRequestAction) + action!: WagonDetachRequestAction; + + @ApiProperty({ + description: 'Why the wagon must leave the scheduled consist. Shown to the approver.', + maxLength: 500, + }) + @IsString() + @IsNotEmpty() + @MaxLength(500) + reason!: string; +} + +export class DecideWagonDetachRequestDto { + @ApiPropertyOptional({ + description: 'Decision note — required when rejecting, optional when approving.', + maxLength: 500, + }) + @IsOptional() + @IsString() + @MaxLength(500) + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/trains/entities/wagon-detach-request.entity.ts b/apps/edr-freight-api/src/modules/trains/entities/wagon-detach-request.entity.ts new file mode 100644 index 000000000..acaaed464 --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/entities/wagon-detach-request.entity.ts @@ -0,0 +1,69 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +export enum WagonDetachRequestAction { + Detach = 'DETACH', + Maintenance = 'MAINTENANCE', +} + +export enum WagonDetachRequestStatus { + Pending = 'PENDING', + Approved = 'APPROVED', + Rejected = 'REJECTED', +} + +/** + * Approval gate for detaching a wagon (or sending it to maintenance) from a + * train that is on a SCHEDULED run. + * + * A draft-schedule or unscheduled train is edited freely; once the run is + * SCHEDULED, pulling a wagon out changes a departure customers already booked + * against, so it becomes a two-person action: one staffer requests with a + * reason, another (holding trains:approve_wagon_detach) approves — approval + * executes the detach immediately. Rows are never deleted: decided rows are + * the audit trail of who asked, who decided, and why. + */ +@Entity({ schema: 'freight', name: 'wagon_detach_requests' }) +@Index(['trainId']) +@Index(['trainId', 'status']) +export class WagonDetachRequest extends BaseEntity { + @Column({ name: 'train_id', type: 'uuid' }) + trainId!: string; + + @Column({ name: 'wagon_id', type: 'uuid' }) + wagonId!: string; + + /** Snapshot — the audit trail must read correctly if the wagon is renumbered or deleted. */ + @Column({ name: 'wagon_number', type: 'varchar', length: 50 }) + wagonNumber!: string; + + @Column({ name: 'action', type: 'varchar', length: 20 }) + action!: WagonDetachRequestAction; + + @Column({ name: 'reason', type: 'varchar', length: 500 }) + reason!: string; + + @Column({ + name: 'status', + type: 'enum', + enum: WagonDetachRequestStatus, + enumName: 'wagon_detach_requests_status_enum', + default: WagonDetachRequestStatus.Pending, + }) + status!: WagonDetachRequestStatus; + + /** IAM user id of the requester. The approver must be a different person. */ + @Column({ name: 'requested_by', type: 'uuid', nullable: true }) + requestedBy?: string | null; + + /** IAM user id of the approver/rejecter; null while pending. */ + @Column({ name: 'decided_by', type: 'uuid', nullable: true }) + decidedBy?: string | null; + + @Column({ name: 'decided_at', type: 'timestamptz', nullable: true }) + decidedAt?: Date | null; + + /** Required on reject, optional on approve. */ + @Column({ name: 'decision_note', type: 'varchar', length: 500, nullable: true }) + decisionNote?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts index 21431a1a4..46c95de95 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts @@ -25,6 +25,10 @@ import { BuildTrainDto } from './dto/build-train.dto'; import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto'; import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto'; import { SendWagonToMaintenanceDto } from './dto/send-wagon-to-maintenance.dto'; +import { + CreateWagonDetachRequestDto, + DecideWagonDetachRequestDto, +} from './dto/wagon-detach-request.dto'; import { UpdateTrainDetailsDto } from './dto/update-train-details.dto'; import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto'; import { UpdateTrainYardDto } from './dto/update-train-yard.dto'; @@ -47,6 +51,7 @@ import { TrainBuilderService } from './train-builder.service'; FREIGHT_PERMS.trains.changeWagonYard, FREIGHT_PERMS.trains.toggleActive, FREIGHT_PERMS.trains.disband, + FREIGHT_PERMS.trains.approveWagonDetach, ]) export class TrainBuilderController { constructor(private readonly trainBuilderService: TrainBuilderService) {} @@ -206,6 +211,74 @@ export class TrainBuilderController { ); } + @Get(':id/detach-requests') + @ApiOperation({ + summary: + 'Detach/maintenance approval requests of this train, newest first — pending and decided alike (the audit trail)', + }) + detachRequests(@Param('id', ParseUUIDPipe) id: string) { + return this.trainBuilderService.listDetachRequests(id); + } + + @Post(':id/wagons/:wagonId/detach-requests') + @FleetManage(FREIGHT_PERMS.trains.assignWagons) + @ApiOperation({ + summary: + 'Request approval to detach a wagon (or send it to maintenance) while the train is on a SCHEDULED run', + }) + createDetachRequest( + @Param('id', ParseUUIDPipe) id: string, + @Param('wagonId', ParseUUIDPipe) wagonId: string, + @Body() dto: CreateWagonDetachRequestDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.trainBuilderService.createDetachRequest( + id, + wagonId, + dto, + resolveAuthUserId(user), + ); + } + + @Post(':id/detach-requests/:requestId/approve') + @FleetManage(FREIGHT_PERMS.trains.approveWagonDetach) + @ApiOperation({ + summary: + 'Approve a detach/maintenance request — the detach executes immediately; the approver must not be the requester', + }) + approveDetachRequest( + @Param('id', ParseUUIDPipe) id: string, + @Param('requestId', ParseUUIDPipe) requestId: string, + @CurrentUser() user: AuthUserPayload, + @Body() dto?: DecideWagonDetachRequestDto, + ) { + return this.trainBuilderService.decideDetachRequest( + id, + requestId, + 'APPROVE', + resolveAuthUserId(user), + dto?.note, + ); + } + + @Post(':id/detach-requests/:requestId/reject') + @FleetManage(FREIGHT_PERMS.trains.approveWagonDetach) + @ApiOperation({ summary: 'Reject a detach/maintenance request — a note explaining why is required' }) + rejectDetachRequest( + @Param('id', ParseUUIDPipe) id: string, + @Param('requestId', ParseUUIDPipe) requestId: string, + @CurrentUser() user: AuthUserPayload, + @Body() dto: DecideWagonDetachRequestDto, + ) { + return this.trainBuilderService.decideDetachRequest( + id, + requestId, + 'REJECT', + resolveAuthUserId(user), + dto.note, + ); + } + @Post(':id/reorder-wagons') @FleetManage(FREIGHT_PERMS.trains.assignWagons) @ApiOperation({ summary: 'Persist a drag-reorder of the full consist' }) diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index d7cfc29c5..09f6559f7 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -30,8 +30,14 @@ import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto'; import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto'; import { UpdateTrainDetailsDto } from './dto/update-train-details.dto'; import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto'; +import { CreateWagonDetachRequestDto } from './dto/wagon-detach-request.dto'; import { TrainLocomotive } from './entities/train-locomotive.entity'; import { Train } from './entities/train.entity'; +import { + WagonDetachRequest, + WagonDetachRequestAction, + WagonDetachRequestStatus, +} from './entities/wagon-detach-request.entity'; import { buildPaginationMeta, normalizePagination, @@ -751,32 +757,43 @@ export class TrainBuilderService { /** Detach one wagon and close the sequence gap it leaves. */ async removeWagon(id: string, wagonId: string, userId?: string | null) { const pending = await this.dataSource.transaction(async (manager) => { - const train = await this.getEditableTrain(manager, id); - const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); - if (!wagon || wagon.trainId !== train.id) { - throw new NotFoundException(`Wagon ${wagonId} is not part of this train`); - } - await this.assertDetachableAndReleaseStaleSlots(manager, wagon); - await manager.getRepository(Wagon).update(wagon.id, { - trainId: null, - sequenceNumber: null, - status: WagonStatus.Available, - importTrainNumber: null, - exportTrainNumber: null, - }); - await this.resequenceWagons(manager, train.id); - return this.syncLiveScheduleAfterConsistChange( - manager, - train.id, - [{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }], - userId ?? null, - wagon.currentYardId ?? train.currentYardId ?? null, - ); + await this.assertDetachNeedsNoApproval(manager, id); + return this.removeWagonCore(manager, id, wagonId, userId); }); await this.reconcileWindowAfterConsistChange(pending); return this.getComposition(id); } + /** Transactional body of removeWagon — also runs under an approved detach request. */ + private async removeWagonCore( + manager: EntityManager, + id: string, + wagonId: string, + userId?: string | null, + ): Promise { + const train = await this.getEditableTrain(manager, id); + const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); + if (!wagon || wagon.trainId !== train.id) { + throw new NotFoundException(`Wagon ${wagonId} is not part of this train`); + } + await this.assertDetachableAndReleaseStaleSlots(manager, wagon); + await manager.getRepository(Wagon).update(wagon.id, { + trainId: null, + sequenceNumber: null, + status: WagonStatus.Available, + importTrainNumber: null, + exportTrainNumber: null, + }); + await this.resequenceWagons(manager, train.id); + return this.syncLiveScheduleAfterConsistChange( + manager, + train.id, + [{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }], + userId ?? null, + wagon.currentYardId ?? train.currentYardId ?? null, + ); + } + /** * Detach one wagon AND flag it for maintenance: it leaves the consist and * moves to MAINTENANCE status (not AVAILABLE), so it is not re-coupled until @@ -789,6 +806,22 @@ export class TrainBuilderService { note?: string | null, ) { const pending = await this.dataSource.transaction(async (manager) => { + await this.assertDetachNeedsNoApproval(manager, id); + return this.sendWagonToMaintenanceCore(manager, id, wagonId, userId, note); + }); + await this.reconcileWindowAfterConsistChange(pending); + return this.getComposition(id); + } + + /** Transactional body of sendWagonToMaintenance — also runs under an approved request. */ + private async sendWagonToMaintenanceCore( + manager: EntityManager, + id: string, + wagonId: string, + userId?: string | null, + note?: string | null, + ): Promise { + { const train = await this.getEditableTrain(manager, id); const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); if (!wagon || wagon.trainId !== train.id) { @@ -851,6 +884,191 @@ export class TrainBuilderService { userId ?? null, yardId, ); + } + } + + /** + * Direct-detach guard: while this train carries a live SCHEDULED run, + * removing a wagon changes a departure customers already booked against, so + * it is a two-person action — refuse here and point at the request flow. + * DRAFT stays freely editable; DISPATCHED is already frozen by + * getEditableTrain (the train is IN_SERVICE). + */ + private async assertDetachNeedsNoApproval( + manager: EntityManager, + trainId: string, + ): Promise { + const scheduled = await this.findScheduledRun(manager, trainId); + if (scheduled) { + throw new ConflictException( + `Train is on scheduled run ${scheduled.reference ?? scheduled.id} — detaching a wagon needs an approved detach request`, + ); + } + } + + private async findScheduledRun( + manager: EntityManager, + trainId: string, + ): Promise<{ id: string; reference: string | null } | null> { + const rows: { id: string; reference: string | null }[] = await manager.query( + `SELECT ts.id, ts.reference + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + WHERE tset.train_id = $1 + AND ts.status = 'SCHEDULED' + AND ts.deleted_at IS NULL + LIMIT 1`, + [trainId], + ); + return rows[0] ?? null; + } + + /** + * File a detach/maintenance approval request for a wagon on a SCHEDULED + * train. The request carries the reason; a different staffer with + * trains:approve_wagon_detach decides it (approval executes the detach). + */ + async createDetachRequest( + id: string, + wagonId: string, + dto: CreateWagonDetachRequestDto, + userId?: string | null, + ) { + return this.dataSource.transaction(async (manager) => { + const train = await this.getEditableTrain(manager, id); + const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); + if (!wagon || wagon.trainId !== train.id) { + throw new NotFoundException(`Wagon ${wagonId} is not part of this train`); + } + const scheduled = await this.findScheduledRun(manager, train.id); + if (!scheduled) { + throw new ConflictException( + 'This train has no SCHEDULED run — detach the wagon directly, no approval needed', + ); + } + // Refuse up front what an approval could never execute (booked + // allocations pin the wagon) — but release nothing yet: slots are only + // touched when the approved detach actually runs. + await this.assertDetachableAndReleaseStaleSlots(manager, wagon, { checkOnly: true }); + const repo = manager.getRepository(WagonDetachRequest); + const open = await repo.findOne({ + where: { trainId: train.id, wagonId: wagon.id, status: WagonDetachRequestStatus.Pending }, + }); + if (open) { + throw new ConflictException( + `Wagon ${wagon.wagonNumber} already has a pending detach request`, + ); + } + return repo.save( + repo.create({ + trainId: train.id, + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + action: dto.action, + reason: dto.reason.trim(), + requestedBy: userId ?? null, + }), + ); + }); + } + + /** All detach/maintenance requests of this train, newest first — the approval audit trail. */ + async listDetachRequests(trainId: string) { + const rows: Array<{ + id: string; + wagonId: string; + wagonNumber: string; + action: string; + reason: string; + status: string; + requestedById: string | null; + requestedBy: string | null; + requestedAt: Date; + decidedBy: string | null; + decidedAt: Date | null; + decisionNote: string | null; + }> = await this.dataSource.query( + `SELECT r.id, + r.wagon_id AS "wagonId", + r.wagon_number AS "wagonNumber", + r.action, + r.reason, + r.status, + r.requested_by AS "requestedById", + COALESCE(ru.username, ru.email) AS "requestedBy", + r.created_at AS "requestedAt", + COALESCE(du.username, du.email) AS "decidedBy", + r.decided_at AS "decidedAt", + r.decision_note AS "decisionNote" + FROM freight.wagon_detach_requests r + LEFT JOIN iam.users ru ON ru.id = r.requested_by + LEFT JOIN iam.users du ON du.id = r.decided_by + WHERE r.train_id = $1 + AND r.deleted_at IS NULL + ORDER BY r.created_at DESC + LIMIT 100`, + [trainId], + ); + return rows; + } + + /** + * Decide a pending request. Approve executes the detach (or maintenance + * move) in the same transaction that stamps the decision, so an approved row + * can never exist without its detach having happened. The requester cannot + * approve their own request; a rejection must carry a note. + */ + async decideDetachRequest( + id: string, + requestId: string, + decision: 'APPROVE' | 'REJECT', + userId?: string | null, + note?: string | null, + ) { + const pending = await this.dataSource.transaction(async (manager) => { + const repo = manager.getRepository(WagonDetachRequest); + const request = await repo.findOne({ + where: { id: requestId, trainId: id }, + lock: { mode: 'pessimistic_write' }, + }); + if (!request) { + throw new NotFoundException(`Detach request ${requestId} not found on this train`); + } + if (request.status !== WagonDetachRequestStatus.Pending) { + throw new ConflictException( + `This request was already ${request.status.toLowerCase()}`, + ); + } + const decisionNote = note?.trim() || null; + if (decision === 'REJECT') { + if (!decisionNote) { + throw new BadRequestException('A note explaining the rejection is required'); + } + await repo.update(request.id, { + status: WagonDetachRequestStatus.Rejected, + decidedBy: userId ?? null, + decidedAt: new Date(), + decisionNote, + }); + return null; + } + // The 4-eyes point of the gate: requester and approver are different people. + if (request.requestedBy && userId && request.requestedBy === userId) { + throw new ConflictException( + 'You filed this request — a different staff member must approve it', + ); + } + const pendingCheck = + request.action === WagonDetachRequestAction.Maintenance + ? await this.sendWagonToMaintenanceCore(manager, id, request.wagonId, userId, request.reason) + : await this.removeWagonCore(manager, id, request.wagonId, userId); + await repo.update(request.id, { + status: WagonDetachRequestStatus.Approved, + decidedBy: userId ?? null, + decidedAt: new Date(), + decisionNote, + }); + return pendingCheck; }); await this.reconcileWindowAfterConsistChange(pending); return this.getComposition(id); @@ -893,6 +1111,7 @@ export class TrainBuilderService { private async assertDetachableAndReleaseStaleSlots( manager: EntityManager, wagon: Wagon, + opts: { checkOnly?: boolean } = {}, ): Promise { const rows: { id: string; train_set_id: string; status: string; allocs: string }[] = await manager.query( @@ -915,6 +1134,7 @@ export class TrainBuilderService { `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`, ); } + if (opts.checkOnly) return; await manager.getRepository(TrainSetWagon).delete(rows.map((r) => r.id)); for (const trainSetId of [...new Set(rows.map((r) => r.train_set_id))]) { const remaining = await manager.getRepository(TrainSetWagon).find({ diff --git a/apps/edr-freight-api/src/modules/trains/trains.module.ts b/apps/edr-freight-api/src/modules/trains/trains.module.ts index 6009ebef7..1f50681cd 100644 --- a/apps/edr-freight-api/src/modules/trains/trains.module.ts +++ b/apps/edr-freight-api/src/modules/trains/trains.module.ts @@ -4,13 +4,17 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; import { TrainLocomotive } from './entities/train-locomotive.entity'; import { Train } from './entities/train.entity'; +import { WagonDetachRequest } from './entities/wagon-detach-request.entity'; import { TrainBuilderController } from './train-builder.controller'; import { TrainBuilderService } from './train-builder.service'; import { TrainsController } from './trains.controller'; import { TrainsService } from './trains.service'; @Module({ - imports: [TypeOrmModule.forFeature([Train, TrainLocomotive]), TrainSchedulingModule], + imports: [ + TypeOrmModule.forFeature([Train, TrainLocomotive, WagonDetachRequest]), + TrainSchedulingModule, + ], controllers: [TrainsController, TrainBuilderController], providers: [TrainsService, TrainBuilderService], exports: [TrainsService, TrainBuilderService], diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.controller.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.controller.ts index 977e677f8..3a94bb05a 100644 --- a/apps/edr-freight-api/src/modules/verifayda/verifayda.controller.ts +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.controller.ts @@ -17,7 +17,7 @@ import { } from '@nestjs/swagger'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; -import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { FreightJwtGuard } from '../../common/freight-jwt.guard'; import { OptionalJwtGuard } from './optional-jwt.guard'; import { CompleteVerificationResultDto, @@ -91,7 +91,7 @@ export class VerifaydaController { } @Get('status') - @UseGuards(JwtGuard) + @UseGuards(FreightJwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: "Get the current user's Fayda verification status", diff --git a/apps/edr-freight-api/src/scripts/import-mor-locations.ts b/apps/edr-freight-api/src/scripts/import-mor-locations.ts new file mode 100644 index 000000000..b26305c6a --- /dev/null +++ b/apps/edr-freight-api/src/scripts/import-mor-locations.ts @@ -0,0 +1,280 @@ +/** + * Converts a Ministry of Revenues EIMS location workbook into `src/config/mor-locations.data.ts`. + * + * pnpm --filter @edr/freight-api eims:import-locations [--sheet SHEET_NAME] + * + * Exists so a future MoR workbook replaces the dataset by rerunning one command and reviewing the + * diff, instead of anyone hand-editing a thousand rows of tax reference data. Production never + * parses the workbook: the committed TypeScript is what ships. + * + * The generated file reproduces the Ministry's own labels and IDs verbatim. This script validates + * and reports; it does not correct. Spelling compatibility with EDR/e-Trade names lives in + * `mor-location.resolver.ts`, so the dataset stays traceable back to the source sheet. + */ +import { writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { Workbook } from "exceljs"; + +const DEFAULT_SHEET = "EIMS_COUNTRY_REGION_VW"; +const OUTPUT = resolve(__dirname, "../config/mor-locations.data.ts"); + +const COLUMNS = [ + "COUNTRY_NO", + "COUNTRY_NAME", + "PARISH_NO", + "PARISH_NAME", + "CITY_NO", + "CITY_NAME", + "LOCALITY_NO", + "LOCALITY_DESC", +] as const; + +type Column = (typeof COLUMNS)[number]; +type Row = [number, string, number, string, number, string, number, string]; + +/** Header cells arrive with stray casing, spaces and non-breaking spaces; compare on this form. */ +const headerKey = (value: unknown): string => + String(value ?? "") + .toUpperCase() + .replace(/[^A-Z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + +/** + * Cell text with only the transport-layer damage removed (Excel's non-breaking spaces, and the + * CR/LF a wrapped cell leaves behind). Deliberately keeps the Ministry's own leading/trailing + * spaces and spelling — the resolver normalizes at comparison time, the dataset stays as supplied. + */ +const cellText = (value: unknown): string => { + if (value === null || value === undefined) return ""; + const raw = + typeof value === "object" && "text" in (value as object) + ? String((value as { text: unknown }).text ?? "") + : String(value); + return raw.replace(/\u00a0/g, " ").replace(/\r?\n/g, " "); +}; + +const cellNumber = (value: unknown): number | null => { + const text = cellText(value).trim(); + if (!/^[0-9]+$/.test(text)) return null; + return Number(text); +}; + +async function main(): Promise { + const args = process.argv.slice(2); + const sheetFlag = args.indexOf("--sheet"); + const sheetName = sheetFlag >= 0 ? args[sheetFlag + 1] : DEFAULT_SHEET; + const workbookPath = args.find( + (arg, i) => !arg.startsWith("--") && (sheetFlag < 0 || i !== sheetFlag + 1), + ); + + if (!workbookPath) { + throw new Error( + "Usage: eims:import-locations [--sheet SHEET_NAME]\n" + + `Defaults to sheet "${DEFAULT_SHEET}".`, + ); + } + + const workbook = new Workbook(); + await workbook.xlsx.readFile(resolve(process.cwd(), workbookPath)); + + const sheet = workbook.getWorksheet(sheetName); + if (!sheet) { + const available = workbook.worksheets.map((w) => w.name).join(", "); + throw new Error(`Sheet "${sheetName}" not found. Sheets in this workbook: ${available}`); + } + + // The header is not guaranteed to be row 1 — find the first row carrying every required column. + let headerRow = 0; + let columnIndex: Partial> = {}; + for (let r = 1; r <= Math.min(sheet.rowCount, 20); r++) { + const found: Partial> = {}; + sheet.getRow(r).eachCell({ includeEmpty: false }, (cell, colNumber) => { + const key = headerKey(cell.value) as Column; + if (COLUMNS.includes(key) && found[key] === undefined) found[key] = colNumber; + }); + if (COLUMNS.every((c) => found[c] !== undefined)) { + headerRow = r; + columnIndex = found; + break; + } + } + if (headerRow === 0) { + throw new Error( + `Sheet "${sheetName}" has no header row containing all required columns: ${COLUMNS.join(", ")}`, + ); + } + + const rows: Row[] = []; + const problems: string[] = []; + + for (let r = headerRow + 1; r <= sheet.rowCount; r++) { + const sheetRow = sheet.getRow(r); + const at = (column: Column): unknown => sheetRow.getCell(columnIndex[column]!).value; + + // A sheet exported from a view is usually padded with blank rows at the end; skip silently. + if (COLUMNS.every((c) => cellText(at(c)).trim() === "")) continue; + + const countryNo = cellNumber(at("COUNTRY_NO")); + const parishNo = cellNumber(at("PARISH_NO")); + const cityNo = cellNumber(at("CITY_NO")); + const localityNo = cellNumber(at("LOCALITY_NO")); + const countryName = cellText(at("COUNTRY_NAME")); + const parishName = cellText(at("PARISH_NAME")); + const cityName = cellText(at("CITY_NAME")); + const localityDesc = cellText(at("LOCALITY_DESC")); + + const missingIds = ( + [ + ["COUNTRY_NO", countryNo], + ["PARISH_NO", parishNo], + ["CITY_NO", cityNo], + ["LOCALITY_NO", localityNo], + ] as const + ) + .filter(([, value]) => value === null) + .map(([name]) => name); + const blankNames = ( + [ + ["COUNTRY_NAME", countryName], + ["PARISH_NAME", parishName], + ["CITY_NAME", cityName], + ["LOCALITY_DESC", localityDesc], + ] as const + ) + .filter(([, value]) => value.trim() === "") + .map(([name]) => name); + + if (missingIds.length > 0 || blankNames.length > 0) { + problems.push( + `row ${r}: ${[ + missingIds.length ? `non-numeric/missing ${missingIds.join(", ")}` : "", + blankNames.length ? `blank ${blankNames.join(", ")}` : "", + ] + .filter(Boolean) + .join("; ")}`, + ); + continue; + } + + rows.push([ + countryNo!, + countryName, + parishNo!, + parishName, + cityNo!, + cityName, + localityNo!, + localityDesc, + ]); + } + + if (problems.length > 0) { + throw new Error( + `Sheet "${sheetName}" has ${problems.length} unusable row(s); nothing was written:\n ` + + problems.slice(0, 25).join("\n ") + + (problems.length > 25 ? `\n ... and ${problems.length - 25} more` : ""), + ); + } + if (rows.length === 0) { + throw new Error(`Sheet "${sheetName}" has a valid header but no data rows.`); + } + + // Exact duplicates carry no information and only inflate the file — collapsed here, and the + // count reported, so the collapse is a stated decision rather than a silent one. IDs are never + // touched: only whole identical rows are dropped. + const seen = new Map(); + for (const row of rows) { + const key = JSON.stringify(row); + if (!seen.has(key)) seen.set(key, row); + } + const unique = [...seen.values()]; + const duplicates = rows.length - unique.length; + + // Deterministic output: same workbook in, byte-identical file out, so a regeneration diff shows + // only what the Ministry actually changed. + unique.sort( + (a, b) => + a[0] - b[0] || + a[2] - b[2] || + a[4] - b[4] || + a[6] - b[6] || + a[7].localeCompare(b[7]) || + a[5].localeCompare(b[5]), + ); + + // A name that resolves to two different codes under the same parent cannot be resolved by any + // amount of normalization — the resolver refuses it as ambiguous at filing time rather than + // picking one. Reported here so it can be raised with MoR instead of surfacing on a live invoice. + const byPath = new Map>(); + const collide = (level: string, path: string, name: string, no: number): void => { + const key = [level, path, name.trim().toUpperCase().replace(/\s+/g, " ")].join(" | "); + if (!byPath.has(key)) byPath.set(key, new Set()); + byPath.get(key)!.add(no); + }; + for (const [cNo, cName, pNo, pName, tNo, tName, lNo, lName] of unique) { + collide("COUNTRY_NAME", "", cName, cNo); + collide("PARISH_NAME", String(cNo), pName, pNo); + collide("CITY_NAME", `${cNo}/${pNo}`, tName, tNo); + collide("LOCALITY_DESC", `${cNo}/${pNo}/${tNo}`, lName, lNo); + } + const conflicts = [...byPath.entries()] + .filter(([, codes]) => codes.size > 1) + .map(([key, codes]) => { + const [level, path, name] = key.split(" | "); + const where = path ? ` under ${path}` : ""; + return ` ${level} "${name}"${where} -> codes ${[...codes].sort((a, b) => a - b).join(", ")}`; + }) + .sort(); + + const header = `/** + * GENERATED FILE — do not hand-edit. + * + * MoR EIMS location master (\`${sheetName}\`), the Ministry's own geographic reference data. + * Regenerate from a supplied workbook with: + * + * pnpm --filter @edr/freight-api eims:import-locations + * + * Values are reproduced verbatim from the Ministry sheet — original spelling, original casing, + * original numbering. Nothing here is cleaned up or renumbered: this file is the traceable copy of + * the source. Spelling compatibility between EDR/e-Trade names and MoR names belongs in + * \`mor-location.resolver.ts\`'s normalization and alias layer, never here. + */ + +/** \`[COUNTRY_NO, COUNTRY_NAME, PARISH_NO, PARISH_NAME, CITY_NO, CITY_NAME, LOCALITY_NO, LOCALITY_DESC]\` */ +export type MorLocationTuple = [number, string, number, string, number, string, number, string]; + +export const MOR_LOCATIONS: MorLocationTuple[] = [ +`; + const body = unique + .map( + ([cNo, cName, pNo, pName, tNo, tName, lNo, lName]) => + ` [${cNo}, ${JSON.stringify(cName)}, ${pNo}, ${JSON.stringify(pName)}, ${tNo}, ` + + `${JSON.stringify(tName)}, ${lNo}, ${JSON.stringify(lName)}],`, + ) + .join("\n"); + + writeFileSync(OUTPUT, `${header}${body}\n];\n`, "utf8"); + + const countries = new Set(unique.map((r) => r[0])).size; + const regions = new Set(unique.map((r) => `${r[0]}/${r[2]}`)).size; + const zones = new Set(unique.map((r) => `${r[0]}/${r[2]}/${r[4]}`)).size; + + console.log(`Wrote ${OUTPUT}`); + console.log( + ` ${unique.length} rows - ${countries} countries, ${regions} regions, ${zones} zones` + + (duplicates > 0 ? `; collapsed ${duplicates} exact duplicate row(s)` : ""), + ); + if (conflicts.length > 0) { + console.log( + ` ${conflicts.length} same-hierarchy name conflict(s) - these resolve to an ambiguity ` + + "error at filing time, never to a guess:", + ); + console.log(conflicts.join("\n")); + } +} + +main().catch((err: Error) => { + console.error(err.message); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/scripts/seed-occ-july-2026.ts b/apps/edr-freight-api/src/scripts/seed-occ-july-2026.ts index 120347132..b2082db93 100644 --- a/apps/edr-freight-api/src/scripts/seed-occ-july-2026.ts +++ b/apps/edr-freight-api/src/scripts/seed-occ-july-2026.ts @@ -487,6 +487,12 @@ async function upsertTrain(ds: DataSource, code: string): Promise { return row.id; } +/** The handling window a stop records — the loading/unloading report's input. */ +interface Handling { + unloadingStartedAt: Date; + loadingCompletedAt: Date; +} + async function upsertCheckpoint( ds: DataSource, scheduleId: string, @@ -495,7 +501,18 @@ async function upsertCheckpoint( kind: 'ARRIVED' | 'DEPARTED', occurredAt: Date, note: string, + handling?: Handling, ): Promise { + const params = [ + scheduleId, + yardId, + sequenceNo, + kind, + occurredAt, + note, + handling?.unloadingStartedAt ?? null, + handling?.loadingCompletedAt ?? null, + ]; const existing = await ds.query>( `SELECT id FROM freight.train_checkpoint_events WHERE train_schedule_id = $1 AND yard_id = $2 AND kind = $3 AND deleted_at IS NULL`, @@ -503,17 +520,20 @@ async function upsertCheckpoint( ); if (existing.length) { await ds.query( - `UPDATE freight.train_checkpoint_events SET occurred_at = $2, note = $3, updated_at = now() + `UPDATE freight.train_checkpoint_events + SET occurred_at = $2, note = $3, + unloading_started_at = $4, loading_completed_at = $5, updated_at = now() WHERE id = $1`, - [existing[0].id, occurredAt, note], + [existing[0].id, occurredAt, note, params[6], params[7]], ); return; } await ds.query( `INSERT INTO freight.train_checkpoint_events - (train_schedule_id, yard_id, sequence_no, kind, occurred_at, note) - VALUES ($1, $2, $3, $4, $5, $6)`, - [scheduleId, yardId, sequenceNo, kind, occurredAt, note], + (train_schedule_id, yard_id, sequence_no, kind, occurred_at, note, + unloading_started_at, loading_completed_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + params, ); } @@ -605,13 +625,17 @@ async function seedDmpTrains(ds: DataSource, ids: Ids): Promise { arrivedAt: arrivedGelan, }); - // The loading/unloading figure has nowhere of its own to live yet — no - // table records when handling starts and ends — so it rides on the stop's - // note, where the staying-time report surfaces it as the stop's reason. + // The measured handling window, on the arrival row the staying-time report + // builds the stop from: work starts when the train lands and ends when + // loading finishes, which is what the OCC figure measures. The rest of the + // stay reports as other activity. const note = `OCC July 2026 — loading/unloading ${handlingHours.toFixed(2)}h of ` + `${stayingHours.toFixed(2)}h total staying`; - await upsertCheckpoint(ds, schedule, dmp, 0, 'ARRIVED', arrivedAtDmp, note); + await upsertCheckpoint(ds, schedule, dmp, 0, 'ARRIVED', arrivedAtDmp, note, { + unloadingStartedAt: arrivedAtDmp, + loadingCompletedAt: addHours(arrivedAtDmp, handlingHours), + }); await upsertCheckpoint(ds, schedule, dmp, 0, 'DEPARTED', departedDmp, note); } console.log(`DMP trains : ${DMP_TRAINS.length} trains with measured staying times`); diff --git a/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts b/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts index 6255c90fc..5ddc99cca 100644 --- a/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts +++ b/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts @@ -4,7 +4,7 @@ import type { } from "../../modules/contract-templates/entities/contract-template.entity"; /** - * Default article packs for the ten contract templates, transcribed from the + * Default article packs for the fourteen contract templates, transcribed from the * signed EDR contract documents (test/contrat_docs). Article bodies use the * dynamic-article text format: one clause per line, "- " prefix for bullets * nested under the previous clause, single-line body = plain paragraph. @@ -23,7 +23,8 @@ export interface ContractTemplateSeed { /** * A base pack keyed by direction/freight only. Each one is transcribed from a * signed EDR contract and is split at the bottom of this file into the - * `_CUSTOMS` / `_NO_CUSTOMS` pair the template table actually stores. + * `_CUSTOMS` / `_ETHIOPIAN_CUSTOMS` / `_NO_CUSTOMS` trio the template table + * actually stores. */ type ContractTemplateBase = Omit; @@ -883,7 +884,35 @@ Settle assessed duties and taxes within the period notified by the Service Provi ), ]; -/** Build the stored `_CUSTOMS` / `_NO_CUSTOMS` pair for one base pack. */ +/** + * Articles appended to the `_ETHIOPIAN_CUSTOMS` variant: the Service Provider + * clears the Ethiopian side only, while Djibouti clearing stays with the + * Client. Article ids match the full-customs pack so downstream checks treat + * both as customs-clearing variants. + */ +const ETHIOPIAN_CUSTOMS_ARTICLES: Array> = [ + a( + "customs-clearing", + "Ethiopian Customs Clearing Services", + `The Service Provider shall carry out customs clearing on behalf of the Client for the cargo covered by this Agreement at the customs stations of Ethiopia only, including declaration, lodgement, and follow-up as applicable to the agreed corridor. +Customs clearing at Djibouti is not included in this Agreement and remains the sole responsibility of the Client. +The Service Provider shall act only within the authority granted by the Client and shall not amend a declaration without the Client's written instruction. +Customs duties, taxes, and any government charges assessed on the cargo remain payable by the Client and are not included in the freight price; the Service Provider shall settle them on the Client's behalf only where the Client has placed the corresponding funds in advance. +The Service Provider shall hand over all customs documents obtained in the course of clearing to the Client upon completion of each shipment.`, + ), + a( + "customs-client-duties", + "Client Obligations for Ethiopian Customs Clearing", + `Grant the Service Provider a duly signed and stamped power of attorney authorising it to act as the Client's customs agent in Ethiopia for the duration of this Agreement. +Complete customs clearing at Djibouti and deliver the cargo customs-cleared on the Djibouti side, together with the supporting release documents, in time for the scheduled railway loading. +Submit every document required for declaration (commercial invoice, packing list, bill of lading or airway bill, permits, certificates of origin, and any authority-specific licence) within one (1) calendar day of the Service Provider's request. +Warrant that the declared description, quantity, value, and tariff classification of the cargo are complete and accurate. +Bear any penalty, demurrage, storage, or re-inspection cost arising from incorrect, incomplete, or late Client-supplied information or documentation, or from delayed Djibouti-side clearing. +Settle assessed duties and taxes within the period notified by the Service Provider, failing which the Service Provider may suspend clearing and the cargo shall remain at the Client's risk and cost.`, + ), +]; + +/** Build the stored `_CUSTOMS` / `_ETHIOPIAN_CUSTOMS` / `_NO_CUSTOMS` trio for one base pack. */ function splitByCustoms( base: ContractTemplateBase, codeStem: string, @@ -896,6 +925,14 @@ function splitByCustoms( description: `${base.description} Customs clearing is performed by the Service Provider.`, articles: [...base.articles, ...CUSTOMS_ARTICLES], }, + { + ...base, + code: `${codeStem}_ETHIOPIAN_CUSTOMS` as ContractTemplateCode, + name: `${base.name} (Ethiopian customs clearing only)`, + description: `${base.description} Only Ethiopian customs clearing is performed by the Service Provider; Djibouti clearing is handled by the Client.`, + documentTitle: `${base.documentTitle} (Ethiopian Customs Clearing Only)`, + articles: [...base.articles, ...ETHIOPIAN_CUSTOMS_ARTICLES], + }, { ...base, code: `${codeStem}_NO_CUSTOMS` as ContractTemplateCode, @@ -907,7 +944,8 @@ function splitByCustoms( } /** - * Ten templates: import and export each split by customs clearing, intercity + * Fourteen templates: import and export each split by customs clearing option + * (full, Ethiopian-only, none), intercity * not split at all — it is a domestic Ethiopian movement that crosses no * border, so there is no customs leg to contract for. */ diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 8204ee2d3..ac2507aa5 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -97,6 +97,8 @@ const SEEDED_REPORT_KEYS = [ "cargo-volume-performance", "charged-vs-actual-volume", "cargo-volume-by-station", + "port-warehouse-summary", + "loading-unloading", ] as const; /** @@ -1012,6 +1014,14 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:trains:change_wagon_yard", "Change yard of a coupled wagon", ), + // Supervisor-only: NOT part of FLEET_GRANULAR_KEYS — detach requests are + // filed under trains:assign_wagons, but deciding them is a separate grant so + // the requester and approver are different people. + perm( + "e1c00001-0001-4000-8000-000000000011", + "edr_freight_app:trains:approve_wagon_detach", + "Approve wagon detach/maintenance requests", + ), perm( "e1d00001-0001-4000-8000-000000000001", "edr_freight_app:routes:view", @@ -1483,6 +1493,30 @@ export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:train_scheduling:unload", "Confirm cargo unloaded (import, export, intercity)", ), + // Per-station loading/unloading time windows: the four buttons are separate + // permissions so start and end can be granted to different people. Booking + // load/unload additionally requires the matching window to have been started + // at that yard. + perm( + "a2a00001-0001-4000-8000-000000000008", + "edr_freight_app:train_scheduling:loading_start", + "Start a station's loading window", + ), + perm( + "a2a00001-0001-4000-8000-000000000009", + "edr_freight_app:train_scheduling:loading_end", + "End a station's loading window", + ), + perm( + "a2a00001-0001-4000-8000-000000000010", + "edr_freight_app:train_scheduling:unloading_start", + "Start a station's unloading window", + ), + perm( + "a2a00001-0001-4000-8000-000000000011", + "edr_freight_app:train_scheduling:unloading_end", + "End a station's unloading window", + ), ]; // L. Administration & settings (split from the coarse admin umbrella) @@ -2019,6 +2053,11 @@ export const FREIGHT_PERMS = { */ load: "edr_freight_app:train_scheduling:load", unload: "edr_freight_app:train_scheduling:unload", + // Per-station loading/unloading time-window buttons (start/end pairs). + loadingStart: "edr_freight_app:train_scheduling:loading_start", + loadingEnd: "edr_freight_app:train_scheduling:loading_end", + unloadingStart: "edr_freight_app:train_scheduling:unloading_start", + unloadingEnd: "edr_freight_app:train_scheduling:unloading_end", dispatch: "edr_freight_app:train_scheduling:dispatch", markPaid: "edr_freight_app:train_scheduling:mark_paid", expireBooking: "edr_freight_app:train_scheduling:expire_booking", @@ -2185,6 +2224,8 @@ export const FREIGHT_PERMS = { changeWagonYard: "edr_freight_app:trains:change_wagon_yard", toggleActive: "edr_freight_app:trains:toggle_active", disband: "edr_freight_app:trains:disband", + /** Decide detach/maintenance requests on a SCHEDULED train (4-eyes gate). */ + approveWagonDetach: "edr_freight_app:trains:approve_wagon_detach", }, routes: { view: "edr_freight_app:routes:view", @@ -2627,6 +2668,10 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.trainScheduling.update, FREIGHT_PERMS.trainScheduling.load, FREIGHT_PERMS.trainScheduling.unload, + FREIGHT_PERMS.trainScheduling.loadingStart, + FREIGHT_PERMS.trainScheduling.loadingEnd, + FREIGHT_PERMS.trainScheduling.unloadingStart, + FREIGHT_PERMS.trainScheduling.unloadingEnd, FREIGHT_PERMS.trainScheduling.cancel, FREIGHT_PERMS.trainScheduling.reschedule, FREIGHT_PERMS.trainScheduling.rulesManage, @@ -2849,6 +2894,10 @@ export const POSITION_PERMISSION_PRESETS = { FREIGHT_PERMS.trainScheduling.update, FREIGHT_PERMS.trainScheduling.load, FREIGHT_PERMS.trainScheduling.unload, + FREIGHT_PERMS.trainScheduling.loadingStart, + FREIGHT_PERMS.trainScheduling.loadingEnd, + FREIGHT_PERMS.trainScheduling.unloadingStart, + FREIGHT_PERMS.trainScheduling.unloadingEnd, FREIGHT_PERMS.trainScheduling.cancel, FREIGHT_PERMS.trainScheduling.reschedule, FREIGHT_PERMS.trainScheduling.rulesManage, diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx index 67851ab30..641541634 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx @@ -128,14 +128,27 @@ export function BookingRouteServiceCard({ background: "#F8FAFC", }} > - - - - Customs clearing agent:{" "} - - {booking.customsClearingAgent} + + + + + Customs clearing agent:{" "} + + {booking.customsClearingAgent} + - + {(booking.customsClearingAgentEmail || + booking.customsClearingAgentPhone) && ( + + {[ + booking.customsClearingAgentEmail, + booking.customsClearingAgentPhone, + ] + .filter(Boolean) + .join(" · ")} + + )} + ) : null} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index dd47ae36e..ff59b1b65 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -88,6 +88,7 @@ import { import { ConsolidationPartnerPanel, emptyPartnerLine, + emptyPartnerUnit, } from "./gl-booking-form/ConsolidationPartnerPanel"; import { ConsolidationPartnerPicker } from "./gl-booking-form/ConsolidationPartnerPicker"; @@ -129,6 +130,7 @@ interface LineErrors { interface BulkErrors { quantity?: string; + wagons?: string; hazardous?: string; reefer?: string; } @@ -174,6 +176,8 @@ interface ContainerLineDraft { interface BulkDraft { cargoWeightTons: string; itemCount: string; + /** NUMBER_OF_WAGONS cargo only: wagons this shipment needs. */ + requestedWagons: string; hazardousQuantity: string; reeferQuantity: string; } @@ -202,7 +206,19 @@ function emptyLine(size: string): ContainerLineDraft { function bulkUnitOfMeasure( contract: Freight.IContract, -): "PER_TON" | "PER_ITEM" { +): "PER_TON" | "PER_ITEM" | "NUMBER_OF_WAGONS" { + // The cargo type's own configured unit wins; the pricing-line sniff below is + // the legacy fallback for contracts loaded without the cargoScope relation. + const configured = contract.cargoScope?.find( + (scope) => scope.cargoType?.unitOfMeasure, + )?.cargoType?.unitOfMeasure; + if ( + configured === "PER_TON" || + configured === "PER_ITEM" || + configured === "NUMBER_OF_WAGONS" + ) { + return configured; + } const hasPerItem = contract.pricingBreakdown?.lineItems?.some( (li) => li.unit === "per_item", ); @@ -250,6 +266,17 @@ export default function GlCreateBookingForm() { enabled: Boolean(requestId), }); + // The shipment request is the customer's order: container sizes/quantities + // and the billing currency are the customer's choices and stay read-only — + // GL enters only per-unit details (numbers, seals, VGM, handling). The + // server enforces the same on completion. + const requestContainersLocked = Boolean( + bookingRequest?.requestedLines?.containers?.length, + ); + const requestBulkLocked = + bookingRequest?.requestedLines?.bulk?.cargoWeightTons != null; + const requestCurrencyLocked = Boolean(bookingRequest?.paymentCurrency); + // The expired booking a Rebook is copying from (its cargo seeds the form). const { data: copyFromBooking } = useQuery({ queryKey: ["rebook-copy-from", copyFromParam], @@ -310,6 +337,7 @@ export default function GlCreateBookingForm() { const [bulk, setBulk] = useState({ cargoWeightTons: "", itemCount: "", + requestedWagons: "", hazardousQuantity: "0", reeferQuantity: "0", }); @@ -328,6 +356,39 @@ export default function GlCreateBookingForm() { const [partner, setPartner] = useState(null); const [partnerLines, setPartnerLines] = useState([]); const [partnerCargoDescription, setPartnerCargoDescription] = useState(""); + + // The partner is its own customer: if a shipment request created it, that + // request locks the partner's quantities and billing currency the same way + // this booking's request locks this side (server enforces both halves). + const { data: partnerContractRequests } = useQuery({ + queryKey: ["shipment-requests-for-contract", partner?.contractId], + queryFn: () => contractsService.listBookingRequests(partner!.contractId!), + enabled: Boolean(partner?.contractId), + }); + const partnerRequest = + (partner && + partnerContractRequests?.find( + (r) => r.createdBookingId === partner.id, + )) || + null; + const partnerLocked = Boolean(partnerRequest?.requestedLines?.containers?.length); + + // Seed (and lock) the partner's lines from its request once it loads. + useEffect(() => { + const requested = partnerRequest?.requestedLines?.containers; + if (!partner || !requested?.length) return; + setPartnerLines( + requested.map((c) => ({ + containerSize: c.containerSize, + quantity: String(Math.max(1, c.quantity)), + hazardousQuantity: "0", + reeferQuantity: "0", + returnQuantity: "0", + units: Array.from({ length: Math.max(1, c.quantity) }, emptyPartnerUnit), + })), + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [partner?.id, partnerRequest?.id]); const seededRef = useRef(false); const returnSeededRef = useRef(false); @@ -476,20 +537,30 @@ export default function GlCreateBookingForm() { })), ); } else if (lines.bulk) { - setBulk({ + setBulk((b) => ({ cargoWeightTons: - lines.bulk.cargoWeightTons != null - ? String(lines.bulk.cargoWeightTons) + lines.bulk!.cargoWeightTons != null + ? String(lines.bulk!.cargoWeightTons) : "", itemCount: - lines.bulk.itemCount != null ? String(lines.bulk.itemCount) : "", - hazardousQuantity: String(lines.bulk.hazardousQuantity ?? 0), + lines.bulk!.itemCount != null ? String(lines.bulk!.itemCount) : "", + // The request never carries a wagon count — GL enters it here. + requestedWagons: b.requestedWagons, + hazardousQuantity: String(lines.bulk!.hazardousQuantity ?? 0), reeferQuantity: "0", - }); + })); } if (bookingRequest.contractRouteId) setContractRouteId(bookingRequest.contractRouteId); if (bookingRequest.notes) setNotes(bookingRequest.notes); + // Currency is the customer's choice on the request — seed it here; the + // selector below is disabled while the request specifies one. + if ( + bookingRequest.paymentCurrency === "USD" || + bookingRequest.paymentCurrency === "ETB" + ) { + setPaymentCurrency(bookingRequest.paymentCurrency); + } }, [bookingRequest, prefilled]); // Rebook seed: copy the source booking's container lines once. (Bulk weight / @@ -565,6 +636,7 @@ export default function GlCreateBookingForm() { returnQuantity: Number(l.returnQuantity || 0), })), bulkQuantity: Number(bulk.cargoWeightTons || bulk.itemCount || 0), + bulkRequestedWagons: Number(bulk.requestedWagons || 0), bulkHazardousQuantity: Number(bulk.hazardousQuantity || 0), bulkReeferQuantity: Number(bulk.reeferQuantity || 0), }), @@ -926,6 +998,12 @@ export default function GlCreateBookingForm() { if (Number.isNaN(qty) || qty <= 0) { errs.quantity = "Enter a quantity greater than 0."; } + if (bulkUom === "NUMBER_OF_WAGONS") { + const wagons = Number(bulk.requestedWagons || 0); + if (!Number.isInteger(wagons) || wagons < 1) { + errs.wagons = "Enter the number of wagons needed (at least 1)."; + } + } const h = Number(bulk.hazardousQuantity || 0); if (Number.isNaN(h) || h < 0) { errs.hazardous = "Enter a valid hazardous quantity."; @@ -964,7 +1042,10 @@ export default function GlCreateBookingForm() { line.every((e) => !e.containerNumber && !e.vgmTons), ) && !cargoDescriptionError - : !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer; + : !bulkErrors.quantity && + !bulkErrors.wagons && + !bulkErrors.hazardous && + !bulkErrors.reefer; // COMPLETION never blocks on an odd 20ft total: a customs instance can share // the wagon via the manual pair (consolidationActive), and anything else is @@ -1099,6 +1180,9 @@ export default function GlCreateBookingForm() { reeferQuantity: Number(bulk.reeferQuantity || 0) || undefined, }, ]; + if (bulkUom === "NUMBER_OF_WAGONS" && bulk.requestedWagons !== "") { + payload.requestedWagons = Number(bulk.requestedWagons); + } } return payload; @@ -1114,7 +1198,13 @@ export default function GlCreateBookingForm() { if (!partner || !consolidationActive) return null; const payload: Freight.CreateBookingUnderContractDto = { - paymentCurrency: effectiveCurrency, + // The partner's customer chose its own currency on its shipment request; + // only a partner without a request falls back to this booking's currency. + paymentCurrency: + partnerRequest?.paymentCurrency === "USD" || + partnerRequest?.paymentCurrency === "ETB" + ? partnerRequest.paymentCurrency + : effectiveCurrency, ...(scheduledDate ? { scheduledDate: new Date(scheduledDate).toISOString() } : {}), @@ -1663,6 +1753,12 @@ export default function GlCreateBookingForm() { label="Quantity *" min={0} value={line.quantity} + disabled={requestContainersLocked} + description={ + requestContainersLocked + ? "Requested by the customer — quantity cannot be changed." + : undefined + } error={ showErrors ? (lineErrors[lineIdx]?.quantity ?? @@ -1924,6 +2020,7 @@ export default function GlCreateBookingForm() { showReefer={Boolean(contract.isReefer)} showErrors={showErrors} error={partnerError} + lockQuantities={partnerLocked} /> ) : null} @@ -1946,6 +2043,12 @@ export default function GlCreateBookingForm() { placeholder="e.g. 1200" min={0} step={0.01} + disabled={requestBulkLocked} + description={ + requestBulkLocked + ? "Requested by the customer — quantity cannot be changed." + : undefined + } value={bulk.cargoWeightTons} error={ showErrors && bulkUom === "PER_TON" @@ -1977,6 +2080,27 @@ export default function GlCreateBookingForm() { radius={10} styles={fieldStyles} /> + {bulkUom === "NUMBER_OF_WAGONS" && ( + + setBulk((b) => ({ + ...b, + requestedWagons: e.currentTarget.value, + })) + } + radius={10} + styles={fieldStyles} + /> + )} {contract.isHazardous && ( - {isImport - ? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online." - : "Shipments are invoiced in ETB."} + {requestCurrencyLocked + ? "The customer chose the billing currency on the shipment request — it cannot be changed." + : isImport + ? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online." + : "Shipments are invoiced in ETB."} @@ -2410,9 +2536,9 @@ export default function GlCreateBookingForm() { {overweightLines.map((line, i) => ( - {line.containerTypeCode}: {line.totalVgmTons}t exceeds - limit {line.maxAllowedTons}t (+{line.excessTons}t - overweight) + {line.containerLabel || line.containerTypeCode}:{" "} + {line.totalVgmTons}t exceeds limit {line.maxAllowedTons}t + (+{line.excessTons}t overweight) ))} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx index e52cce097..a3be22ea1 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx @@ -86,6 +86,11 @@ interface Props { /** Surface field errors only after the operator tried to continue. */ showErrors: boolean; error?: string; + /** + * The partner's shipment request fixed its sizes/quantities — the quantity + * fields render read-only and GL enters only per-unit details. + */ + lockQuantities?: boolean; } export function ConsolidationPartnerPanel({ @@ -97,6 +102,7 @@ export function ConsolidationPartnerPanel({ showReefer, showErrors, error, + lockQuantities, }: Props) { const patchLine = (index: number, patch: Partial) => { onLinesChange( @@ -149,6 +155,12 @@ export function ConsolidationPartnerPanel({ label="Quantity *" min={0} value={line.quantity} + disabled={lockQuantities} + description={ + lockQuantities + ? "Requested by the partner's customer — quantity cannot be changed." + : undefined + } onChange={(e) => patchLine(lineIdx, { quantity: e.currentTarget.value })} // Sync off the typed value, not the captured `line` — that snapshot // still holds the pre-edit quantity and would write it back. diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts index 9131196c7..3d8448bfa 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts @@ -28,6 +28,8 @@ export interface GlShipmentQuantities { }>; /** Bulk: tons (or item count) + hazardous/reefer qty. */ bulkQuantity: number; + /** NUMBER_OF_WAGONS cargo: the wagon count GL enters (0 otherwise). */ + bulkRequestedWagons: number; bulkHazardousQuantity: number; bulkReeferQuantity: number; } @@ -132,7 +134,6 @@ export function computeGlShipmentTotal( } } } else { - const qty = q.bulkQuantity; const rate = rateFor( (i) => @@ -140,6 +141,9 @@ export function computeGlShipmentTotal( !i.isClearance && !i.conditionalOn, ) ?? items[0]; + // NUMBER_OF_WAGONS cargo: a per-wagon base rate bills the requested count. + const qty = + rate?.unit === "per_wagon" ? q.bulkRequestedWagons : q.bulkQuantity; if (rate && qty > 0) { lines.push({ label: rate.label, @@ -179,8 +183,14 @@ export function computeGlShipmentTotal( // it (the commodity needs lashing). Per-ton scales by tonnage; per-wagon // depends on the wagon capacity the train stocks — shown at real pricing. const lashing = items.find((i) => i.conditionalOn === "has_lashing"); - if (lashing && (lashing.unit === "per_ton" || lashing.unit === "per_item")) { - const tons = q.bulkQuantity; + if ( + lashing && + (lashing.unit === "per_ton" || + lashing.unit === "per_item" || + (lashing.unit === "per_wagon" && q.bulkRequestedWagons > 0)) + ) { + const tons = + lashing.unit === "per_wagon" ? q.bulkRequestedWagons : q.bulkQuantity; if (tons > 0) { lines.push({ label: lashing.label, @@ -208,6 +218,8 @@ export function computeGlShipmentTotal( : boxes; } else if (cl.unit === "per_ton" || cl.unit === "per_item") { qty = q.bulkQuantity; + } else if (cl.unit === "per_wagon") { + qty = q.bulkRequestedWagons; } else if (cl.unit === "flat") { qty = 1; } diff --git a/apps/edr-freight-web/backoffice/src/components/page/PageHeader.tsx b/apps/edr-freight-web/backoffice/src/components/page/PageHeader.tsx index 86beb22fb..2194930ec 100644 --- a/apps/edr-freight-web/backoffice/src/components/page/PageHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/page/PageHeader.tsx @@ -58,9 +58,15 @@ export function PageHeader({ {meta} {subtitle ? ( - - {subtitle} - + typeof subtitle === "string" ? ( + + {subtitle} + + ) : ( + // A component subtitle handles its own layout — truncating it + // to one line would defeat e.g. an expandable description. +
{subtitle}
+ ) ) : null} diff --git a/apps/edr-freight-web/backoffice/src/components/reports/ReportDescription.tsx b/apps/edr-freight-web/backoffice/src/components/reports/ReportDescription.tsx new file mode 100644 index 000000000..69707dac8 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/reports/ReportDescription.tsx @@ -0,0 +1,27 @@ +import { Spoiler, Text } from "@mantine/core"; + +interface ReportDescriptionProps { + text: string; +} + +/** + * Report descriptions run to a paragraph. Clamps to roughly two lines and adds + * a Show more toggle — Spoiler measures the content, so the toggle only appears + * when the text actually overflows. + */ +export function ReportDescription({ text }: ReportDescriptionProps) { + return ( + + + {text} + + + ); +} + +export default ReportDescription; diff --git a/apps/edr-freight-web/backoffice/src/components/reports/ReportExportButton.tsx b/apps/edr-freight-web/backoffice/src/components/reports/ReportExportButton.tsx index 76ff7d628..89cd638a6 100644 --- a/apps/edr-freight-web/backoffice/src/components/reports/ReportExportButton.tsx +++ b/apps/edr-freight-web/backoffice/src/components/reports/ReportExportButton.tsx @@ -1,14 +1,16 @@ import { Button, Checkbox, Group, Modal, Radio, Select, SimpleGrid, Stack, Text } from "@mantine/core"; import { Download, FileSpreadsheet, FileText } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { reportsService } from "@/services/reports.service"; -import type { ReportCatalogEntry, ReportRunParams } from "@/types/reports"; +import type { ReportCatalogEntry, ReportColumn, ReportRunParams } from "@/types/reports"; interface ReportExportButtonProps { def: ReportCatalogEntry; /** Filters + sort currently applied on screen — no key/page/pageSize. */ params: Omit; + /** The columns on screen, which for a report with `hideWhen` is not all of them. */ + columns: ReportColumn[]; } const RECORD_OPTIONS = [ @@ -31,17 +33,21 @@ function saveBlob(blob: Blob, filename: string) { /** One export button: format, which fields, how many records — applies the * filters/sort already on screen. Record count defaults to all (capped * server-side per format). */ -export function ReportExportButton({ def, params }: ReportExportButtonProps) { +export function ReportExportButton({ def, params, columns }: ReportExportButtonProps) { const [opened, setOpened] = useState(false); const [format, setFormat] = useState<"xlsx" | "pdf">("xlsx"); - const [fields, setFields] = useState(def.columns.map((c) => c.key)); + const [fields, setFields] = useState(columns.map((c) => c.key)); const [records, setRecords] = useState("all"); const [exporting, setExporting] = useState(false); - const allSelected = fields.length === def.columns.length; + // A filter change can change which columns exist at all — start over from the + // new set rather than exporting keys the query no longer selects. + useEffect(() => setFields(columns.map((c) => c.key)), [columns]); + + const allSelected = fields.length === columns.length; const toggleField = (key: string) => setFields((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key])); - const toggleAll = () => setFields(allSelected ? [] : def.columns.map((c) => c.key)); + const toggleAll = () => setFields(allSelected ? [] : columns.map((c) => c.key)); const handleDownload = async () => { setExporting(true); @@ -110,7 +116,7 @@ export function ReportExportButton({ def, params }: ReportExportButtonProps) { - {def.columns.map((col) => ( + {columns.map((col) => (
{def.title} - - {def.description} - +
diff --git a/apps/edr-freight-web/backoffice/src/components/reports/ReportView.tsx b/apps/edr-freight-web/backoffice/src/components/reports/ReportView.tsx index 2634e882c..ab6552044 100644 --- a/apps/edr-freight-web/backoffice/src/components/reports/ReportView.tsx +++ b/apps/edr-freight-web/backoffice/src/components/reports/ReportView.tsx @@ -13,6 +13,7 @@ import type { ReportFilterDef, ReportRunParams } from "@/types/reports"; import { DataTable, DataTableFooter, usePagination, type ColumnDef } from "@edr/ui-common"; import { ReportChart } from "./ReportChart"; +import { ReportDescription } from "./ReportDescription"; import { ReportExportButton } from "./ReportExportButton"; import { formatKpiValue, formatReportCell } from "./report-format"; @@ -172,9 +173,29 @@ export function ReportView({ reportKey, idKeyValue, pageHeader, defaultView }: R const total = data?.meta.total ?? 0; const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); + /** + * Columns the applied filters don't hide — see ReportColumn.hideWhen. A + * filter the user hasn't touched counts as its declared default, which is + * the value the server will have used to shape the rows. + */ + const visibleColumns = useMemo(() => { + const applied = appliedParams as Record; + const valueOf = (key: string) => applied[key] ?? def?.filters.find((f) => f.key === key)?.defaultValue; + return (def?.columns ?? []).filter((col) => + Object.entries(col.hideWhen ?? {}).every(([key, value]) => valueOf(key) !== value), + ); + }, [def?.columns, def?.filters, appliedParams]); + + /** A chart whose x or y column is hidden has nothing to plot — drop the toggle. */ + const chartDef = useMemo(() => { + if (!def?.chart) return undefined; + const shown = new Set(visibleColumns.map((c) => c.key)); + return shown.has(def.chart.x) && def.chart.y.every((k) => shown.has(k)) ? def.chart : undefined; + }, [def?.chart, visibleColumns]); + const columns: ColumnDef>[] = useMemo( () => - (def?.columns ?? []).map((col) => ({ + visibleColumns.map((col) => ({ id: col.key, accessorKey: col.key, header: col.sortable @@ -187,7 +208,7 @@ export function ReportView({ reportKey, idKeyValue, pageHeader, defaultView }: R ), })), - [def?.columns], + [visibleColumns], ); if (!def) { @@ -196,7 +217,7 @@ export function ReportView({ reportKey, idKeyValue, pageHeader, defaultView }: R ) : null; } - const chartToggle = def.chart ? ( + const chartToggle = chartDef ? ( ); - const exportButton = ; + const exportButton = ; return ( {pageHeader ? ( } action={ {exportButton} @@ -268,8 +289,8 @@ export function ReportView({ reportKey, idKeyValue, pageHeader, defaultView }: R - {view === "chart" && def.chart ? ( - + {view === "chart" && chartDef ? ( + ) : ( { + if (isAxiosError(error)) { + const message = error.response?.data?.message; + if (Array.isArray(message)) return message.join(", "); + if (typeof message === "string") return message; + } + return fallback; +}; + interface Props { trainId: string; /** Staff may attach and the train is editable (not out on a run). */ @@ -50,6 +62,47 @@ export default function DetachedWagonsPanel({ // Selection is page-scoped in the header checkbox but survives paging, so // staff can gather wagons across pages into one attach. const [selected, setSelected] = useState>(new Set()); + + // Attach the selection to a DIFFERENT built train: pick a target, reuse the + // same assign endpoint with that train's id. The builder attach is + // yard-agnostic, so any loose AVAILABLE wagon qualifies; a train that is + // out on a run rejects server-side and is disabled here too. + const { toast } = useToast(); + const [targetTrainId, setTargetTrainId] = useState(null); + const trainsQuery = useQuery( + api.trainBuilder.list.queryOptions({ + input: { filters: { pageSize: 200, sortBy: "code", sortOrder: "ASC" } }, + enabled: canAttach, + staleTime: 60_000, + }), + ); + const trainOptions = (trainsQuery.data?.items ?? []) + .filter((t) => t.id !== trainId) + .map((t) => ({ + value: t.id, + label: `${t.code}${t.trainName ? ` · ${t.trainName}` : ""} — ${t.wagonCount} wagon${t.wagonCount === 1 ? "" : "s"}${t.status === "IN_SERVICE" ? " (in service)" : ""}`, + disabled: t.status === "IN_SERVICE", + })); + const attachOther = useMutation(api.trainBuilder.assignWagons.mutationOptions()); + const handleAttachOther = async () => { + if (!targetTrainId || !selected.size) return; + const target = trainsQuery.data?.items.find((t) => t.id === targetTrainId); + try { + await attachOther.mutateAsync({ id: targetTrainId, wagonIds: [...selected] }); + toast({ + title: `${selected.size} wagon(s) attached to ${target?.code ?? "the selected train"}`, + }); + setSelected(new Set()); + setTargetTrainId(null); + void query.refetch(); + } catch (error) { + toast({ + title: "Could not attach to the other train", + description: parseError(error, "The target train may be out on a run."), + variant: "destructive", + }); + } + }; const allSelected = rows.length > 0 && rows.every((r) => selected.has(r.wagonId)); const toggle = (wagonId: string, checked: boolean) => @@ -79,17 +132,40 @@ export default function DetachedWagonsPanel({ {canAttach ? ( - + + +