Merge branch 'dev' into reschedule

This commit is contained in:
Abubeker Yasin
2026-08-28 16:36:51 +03:00
637 changed files with 53614 additions and 8442 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 673 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

View File

@@ -0,0 +1,312 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>EDR Freight Portal — Priority Flows</title>
<style>
@page { size: A4; margin: 0; }
* { box-sizing: border-box; }
body {
font-family: -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
color: #1a1a1a;
margin: 0;
font-size: 13px;
line-height: 1.45;
}
.cover {
height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
padding: 60px 70px;
background: linear-gradient(135deg, #0b6b4f 0%, #0e8a63 60%, #14a074 100%);
color: white;
page-break-after: always;
}
.cover .tag { font-size: 14px; letter-spacing: 3px; text-transform: uppercase; opacity: .85; margin-bottom: 18px; }
.cover h1 { font-size: 42px; margin: 0 0 14px 0; line-height: 1.15; }
.cover p.sub { font-size: 17px; opacity: .92; max-width: 520px; }
.cover .meta { margin-top: 60px; font-size: 12px; opacity: .8; border-top: 1px solid rgba(255,255,255,.35); padding-top: 16px; max-width: 520px; }
.cover .meta div { margin-bottom: 4px; }
.toc { padding: 60px 70px; page-break-after: always; }
.toc h2 { font-size: 22px; margin-bottom: 24px; color: #0b6b4f; }
.toc ol { padding-left: 22px; }
.toc li { margin-bottom: 10px; font-size: 14px; }
.toc .section-label { font-weight: 700; color: #0b6b4f; }
section.flow { padding: 40px 60px 10px 60px; }
section.flow > h2 {
font-size: 24px;
color: #0b6b4f;
border-bottom: 3px solid #0b6b4f;
padding-bottom: 8px;
margin-bottom: 6px;
page-break-before: always;
}
section.flow > p.lede { color: #444; font-size: 13.5px; margin-bottom: 22px; }
.step { margin-bottom: 28px; page-break-inside: avoid; }
.step .step-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.step .num {
background: #0b6b4f; color: white; font-weight: 700; font-size: 13px;
width: 26px; height: 26px; border-radius: 50%;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.step .title { font-weight: 700; font-size: 14.5px; }
.step .desc { margin: 0 0 10px 36px; color: #333; font-size: 13px; }
.step img {
display: block;
max-width: 100%;
border: 1px solid #d8dde3;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
}
.note {
margin: 10px 36px 18px 36px;
padding: 10px 14px;
border-left: 4px solid #2f7fd6;
background: #eef5fd;
border-radius: 4px;
font-size: 12.5px;
}
.note.limit { border-left-color: #c98a1c; background: #fdf6e8; }
.note.bug { border-left-color: #c0392b; background: #fbeceb; }
.note.howto { border-left-color: #0b6b4f; background: #eaf6f0; }
.note b { display: block; margin-bottom: 3px; }
.note ol { margin: 6px 0 0 0; padding-left: 18px; }
.note ol li { margin-bottom: 4px; }
</style>
</head>
<body>
<div class="cover">
<div class="tag">EDR Freight — Priority Flows</div>
<h1>Portal Side<br>Contract Signing · Booking · Payment</h1>
<p class="sub">A step-by-step walkthrough of the same three flows, driven live as the real
customer — every screen, button, and validation message as the account actually saw it.</p>
<div class="meta">
<div>Driven live against the shared dev environment (edr_dev)</div>
<div>Personas: hager@gmail.com (Hagernesh Tadesse) for contract &amp; booking; nati@gmail.com (NAti Wondish) for payment</div>
<div>Companion document to EDR-Freight-Priority-Flows.pdf (backoffice/staff side)</div>
</div>
</div>
<div class="toc">
<h2>Contents</h2>
<ol>
<li><span class="section-label">1. Contract creation &amp; signing</span> — Home, Contracts list, the New Contract wizard (Setup → Cargo/Route → Review), the unit-rate quotation approval step, the duplicate-contract validation, and how/where a customer signs once EDR approves.</li>
<li><span class="section-label">2. Booking</span> — the Bookings list and the document-review flow a customer works through on an existing booking.</li>
<li><span class="section-label">3. Payment</span> — the customer's Invoices page, filtering to what's actually payable, the invoice detail screen with its "Pay" action, and the bank-transfer steps behind it.</li>
</ol>
</div>
<section class="flow">
<h2>1 · Contract creation &amp; signing</h2>
<p class="lede">Portal customer hager@gmail.com, logged into the freight portal (localhost:5273).</p>
<div class="step">
<div class="step-head"><div class="num">1</div><div class="title">Sign in</div></div>
<div class="desc">The portal login form. A customer signs in with their email and password — no OTP step for an already-onboarded account (OTP only applies during self-signup).</div>
<img src="{{02-login-full.png}}">
</div>
<div class="step">
<div class="step-head"><div class="num">2</div><div class="title">Home</div></div>
<div class="desc">Landing page after login: quick stats and shortcuts into Contracts, Bookings, and Invoices.</div>
<img src="{{05-home-full.png}}">
</div>
<div class="step">
<div class="step-head"><div class="num">3</div><div class="title">Contracts list</div></div>
<div class="desc">The customer's contracts: one Active (CTR-2026-00043, Fully Executed) and two In Progress (CTR-2026-00086, CTR-2026-00087 — both Submitted, awaiting EDR review). "New Contract" starts a fresh one.</div>
<img src="{{04-contracts-list-full.png}}">
</div>
<div class="step">
<div class="step-head"><div class="num">4</div><div class="title">New Contract — Step 1: Setup</div></div>
<div class="desc">Operation Type, Contract Kind (One-Time vs. Framework), New-vs-Renewal, and the Service Type cards — the entry point of the wizard.</div>
<img src="{{07-contract-step1-full.png}}">
</div>
<div class="step">
<div class="step-head"><div class="num">5</div><div class="title">Step 1 filled — Operation Type + Service</div></div>
<div class="desc">Operation Type set to Import and "Rail Transport with Customs" selected — this reveals the trucking/customs options below (first mile, last mile, and the included customs clearing service).</div>
<img src="{{08-contract-step1-filled-full.png}}">
</div>
<div class="step">
<div class="step-head"><div class="num">6</div><div class="title">Step 2: Cargo &amp; Route</div></div>
<div class="desc">Cargo scope (containerised, both 20ft/40ft), and the origin/destination yard pair that defines the route.</div>
<img src="{{09-contract-step2-full.png}}">
</div>
<div class="step">
<div class="step-head"><div class="num">7</div><div class="title">Step 3: Review &amp; Submit</div></div>
<div class="desc">Final review of the assembled contract terms — operation, service, route, cargo scope, and customs handling — before submitting.</div>
<img src="{{10-contract-step3-review-full.png}}">
</div>
<div class="step">
<div class="step-head"><div class="num">8</div><div class="title">Approve your quotation</div></div>
<div class="desc">Submitting immediately opens a per-unit rate quotation for the customer to approve — freight rates for 20ft/40ft containers plus the customs clearance service fee per container size. The dialog is explicit that these are per-unit rates, not a total: the payable amount is computed per booking from the quantities actually shipped. Approving submits the contract for EDR staff review.</div>
<img src="{{11b-quotation-approve-full.png}}">
</div>
<div class="step">
<div class="step-head"><div class="num">9</div><div class="title">Real validation: duplicate contract blocked</div></div>
<div class="desc">Re-submitting a route/service/cargo-scope combination that already has an active or in-review contract is rejected outright, naming the conflicting contract by number. This is a genuine business rule hit live, not staged.</div>
<img src="{{11-contract-duplicate-blocked-full.png}}">
</div>
<div class="note howto">
<b>Once EDR approves the contract, the customer signs it</b>
A submitted contract moves through EDR review as: <i>Submitted → Pending Approval → Approved →
Approved Pending Signature → Contract Ready.</i> The "View &amp; sign contract" button only
appears once the contract reaches <b>Contract Ready</b> — it shows on the Contracts list row and
on the contract's own detail page, and opens a dedicated preview-and-sign page
(<code>/contracts/:id/view</code>), not a plain button on the detail page.
<ol>
<li>EDR staff approve the contract and price it.</li>
<li>The customer is notified the moment it's ready to sign — <b>SMS, email, and an in-app
notification</b> all fire together (per the customer's description of the real notification
flow — not re-verified against the notification-sending code for this document).</li>
<li>The customer opens the notification or the Contracts list, clicks "View &amp; sign
contract", scrolls the full contract text, and ticks the consent checkbox.</li>
<li>They draw or reuse a saved signature, upload a company stamp, then verify by OTP
(sent to their registered phone/email) to finalize the signature.</li>
<li>Status moves to Signed — Awaiting Staff, then Fully Executed once EDR counter-signs.</li>
</ol>
CTR-2026-00086 and CTR-2026-00087 above are still at Submitted — EDR hasn't approved them yet,
so no sign button is showing for either.
</div>
<div class="step">
<div class="step-head"><div class="num">11</div><div class="title">Where "View &amp; sign" actually shows (a different customer's contract list)</div></div>
<div class="desc">A second real account (nati@gmail.com, 17 contracts) confirms the button live: its Action column carries "View", "Request shipment", "Book shipment", and "Initiate booking" depending on each row's status — the same column shows "View &amp; sign" the moment a row reaches Contract Ready.</div>
<img src="{{16-nati-contracts-list-actions.png}}">
</div>
<div class="note limit">
<b>The signable window is real and it closes</b>
This account's own CTR-2026-00089 was captured with a live "View &amp; sign" button two days ago
(per the customer's own screenshot) — by the time this document was rebuilt, that same contract
had already moved to <b>Expired</b> with no button left to click. Contract Ready is a real,
time-boxed state, not a permanent one. No currently-open contract exists on either test account
at time of writing, so the sign page itself (scroll-to-consent → draw signature → stamp → OTP)
could not be re-captured live in this pass; it was documented from the customer's own screenshots
and code inspection instead.
</div>
<div class="step">
<div class="step-head"><div class="num">10</div><div class="title">Fully executed contract — signatures</div></div>
<div class="desc">CTR-2026-00043's detail page: both STAFF and CUSTOMER signatures are complete and the contract is Fully Executed — the end state of the flow described above.</div>
<img src="{{06-contract-detail-signed-full.png}}">
</div>
</section>
<section class="flow">
<h2>2 · Booking</h2>
<p class="lede">A portal customer does not have a bare "New booking" button on the Bookings page
itself — booking creation is triggered from a signed contract's row, once EDR has cleared it for
shipment (contract status Active). What that entry point actually is depends on the contract's
state: "Initiate booking" opens a brand-new booking; "Book shipment" / "Book" continues one EDR
has already pre-cleared.</p>
<div class="step">
<div class="step-head"><div class="num">1</div><div class="title">Initiate booking — confirmation</div></div>
<div class="desc">Clicking "Initiate booking" on an Active contract (CTR-2026-00071) doesn't create the booking immediately — it confirms first: "This creates a new shipment booking under contract CTR-2026-00071. You'll upload the import documents next, and the shipment quantity is drawn down from your contract's reserved capacity." Cancelled here rather than completed, since creating a live booking is a real mutation.</div>
<img src="{{17-initiate-booking-confirm.png}}">
</div>
<div class="step">
<div class="step-head"><div class="num">2</div><div class="title">Complete Your Booking — cargo details</div></div>
<div class="desc">Following an already-cleared booking's "Book" action (BK-2026-000209, Bulk cargo under CTR-2026-00093) lands on this form: the contract's fixed route, a cargo-details block (Total weight for Bulk cargo — a Container-scoped contract shows an Excel-import table instead), and a Schedule block picking the binding shipment day. The billing currency here is fixed to ETB, paid through the payment gateway. The calendar is real and reactive: it read <i>"Enter your cargo details first — available shipment days depend on the wagons your cargo needs,"</i> and no day was actually open on this route at the time of capture (0 available days) — the same live batch-window behaviour documented in Section 1.</div>
<img src="{{19-complete-booking-cargo-full.png}}">
</div>
<div class="step">
<div class="step-head"><div class="num">3</div><div class="title">Bookings list — one contract's view</div></div>
<div class="desc">This account's one existing booking, BK-2026-000068 on CTR-2026-00043 — status "In review," amount still ETB 0.00 because pricing is finalised after document review, not at booking creation. Every row carries a "Review documents" action.</div>
<img src="{{13-bookings-list-full.png}}">
</div>
<div class="step">
<div class="step-head"><div class="num">4</div><div class="title">Import documents dialog</div></div>
<div class="desc">Clicking "Review documents" opens this in place, no page navigation. All four required import documents — Bill of Lading/Waybill, Packing List, Import declaration, and Ethiopia T1 &amp; Djibouti T1 — show status "Under review," each with View/Download and a "Replace file" drop zone. The banner is explicit: <i>"Only re-upload the documents flagged with a query below — approved documents stay as they are."</i> "Submit documents" stays disabled while nothing is flagged.</div>
<img src="{{14-booking-documents-modal-full.png}}">
</div>
<div class="step">
<div class="step-head"><div class="num">5</div><div class="title">Bookings list — the fuller picture (nati@gmail.com, 38 bookings)</div></div>
<div class="desc">Across a larger real history the Action column carries every state at once: "Book" (cleared, awaiting cargo details), "View" (nothing to do), "Rebook wagons" (a cancelled reservation), "Pay [amount]" (booked, payment outstanding), "Review documents" / "Documents", and "Track" once a train is assigned. BK-2026-000188 shows "Pay 16,323.25 ETB" while its own Status column reads Cancelled — a live inconsistency (the booking lapsed after its payment window closed, but the stale Pay action hadn't been cleared from the row).</div>
<img src="{{18-nati-bookings-list-actions.png}}">
</div>
</section>
<section class="flow">
<h2>3 · Payment</h2>
<p class="lede">The customer-facing payment surface is the Invoices page.</p>
<div class="step">
<div class="step-head"><div class="num">1</div><div class="title">Invoices — hager@gmail.com (nothing to pay)</div></div>
<div class="desc">Outstanding, Overdue, and Total Invoices all read 0. This lines up with the Booking flow above: BK-2026-000068 is still in document review and hasn't been priced, so nothing has been invoiced to this customer yet. The customer supplied a second real login (nati@gmail.com) to reach the actual payment screen.</div>
<img src="{{03-hager-invoices-empty-full.png}}">
</div>
<div class="step">
<div class="step-head"><div class="num">2</div><div class="title">Invoices — nati@gmail.com, filtered to Due</div></div>
<div class="desc">This account has a long invoice history (35 invoices) and 5 currently Outstanding. The status filter's real options are Due / Overdue / Paid / Draft / Cancelled / Refunded; filtering to "Due" narrows the list to what's actually payable right now, each row with a "Pay" action.</div>
<img src="{{01-invoices-due-full.png}}">
</div>
<div class="step">
<div class="step-head"><div class="num">3</div><div class="title">Invoice detail — the Pay screen</div></div>
<div class="desc">Opening INV-20260820-00017 (Br 200.00, a port-charges line item off booking BK-2026-000112) shows the full detail a customer sees before paying: billed-to company, source, issue/due dates, the line-item breakdown, a "Download invoice" action, and a "Pay Br 200.00" button sized to the exact amount due.</div>
<img src="{{00-invoice-detail-pay.png}}">
</div>
<div class="note howto">
<b>What "Pay" opens (from the customer's own screenshot)</b>
Clicking "Pay" opens a "Complete your payment" dialog: the amount due, a red warning —
<i>"Pay only from a bank account registered under your company name — DE BE KE. A payment sent
from an account under any other name will not be recognized as paid"</i> — a payment-method
picker (the option shown was <b>CBE Bill Payment — "Pay at any CBE branch, app or USSD · ETB"</b>),
a "Secured — you'll be redirected to your provider to pay" note, then "Continue to payment".
This was captured by the customer directly, not by this document — every attempt here to click a
live "Pay" button (on this invoice and on two different bookings) was blocked by the harness's own
financial-mutation guard before the dialog could be reached.
</div>
<div class="note howto">
<b>Paying by bank transfer — step by step (customer-provided process)</b>
<ol>
<li>Open your internet banking app or account.</li>
<li>Copy the PNR code EDR sent you by SMS and email.</li>
<li>In your bank's payment menu, choose <b>Travel</b>, then <b>Land Transport</b>, then
<b>EDR Freight</b> as the biller.</li>
<li>Paste the PNR code into the reference/biller-code field.</li>
<li>Confirm the amount shown matches the invoice total, then pay.</li>
</ol>
This is the settlement path behind the "Pay" button above — the bank confirms the PNR against
EDR's own records, so the code must match exactly what was sent. These steps were supplied
directly by the customer describing the real banking flow; they are not something this
document observed inside the portal UI itself, which only exposes the "Pay" button shown above.
</div>
<div class="note limit">
<b>Stopped short of submitting the payment — three separate attempts, all blocked</b>
Clicking "Pay Br 200.00" on the invoice above, and separately clicking "Pay" on two different
bookings (BK-2026-000195 and BK-2026-000188) while rebuilding this document, were all blocked by
the harness's own auto-mode permission classifier as a real financial mutation against the shared
dev database — the same guard that blocked "Confirm paid" on the backoffice side. No workaround
was attempted, per the classifier's own instruction. The invoice screen above is the actual entry
point a customer uses; the backoffice-side companion document (EDR-Freight-Priority-Flows.pdf)
covers staff-side settlement (Transactions → Manual Payments).
</div>
</section>
</body>
</html>

View File

@@ -0,0 +1,25 @@
import { chromium } from '/home/tria/projects/hagernesh/edr-platform/node_modules/.pnpm/playwright-core@1.61.1/node_modules/playwright-core/index.mjs';
import { readFileSync } from 'node:fs';
import path from 'node:path';
const dir = path.dirname(new URL(import.meta.url).pathname);
let html = readFileSync(path.join(dir, 'portal-flows.html'), 'utf8');
html = html.replace(/\{\{([\w.-]+\.png)\}\}/g, (_, file) => {
const buf = readFileSync(path.join(dir, file));
return `data:image/png;base64,${buf.toString('base64')}`;
});
const browser = await chromium.launch({
executablePath: '/home/tria/.cache/ms-playwright/chromium-1237/chrome-linux64/chrome',
});
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'load' });
await page.pdf({
path: '/home/tria/projects/hagernesh/edr-platform/EDR-Freight-Priority-Flows-Portal.pdf',
format: 'A4',
printBackground: true,
margin: { top: '14mm', bottom: '16mm', left: '14mm', right: '14mm' },
});
await browser.close();
console.log('done');

BIN
4_5767239985799371288.xlsx Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
EDR-Freight-User-Guide.pdf Normal file

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
INV-20260812-00005.pdf Normal file

Binary file not shown.

View File

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

View File

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

View File

@@ -0,0 +1,339 @@
/**
* 10 scenarios against the REAL S-2026-00045 consist (42 NW5 + 10 PW2) on the
* real DCT -> DIRE_DAWA -> GMP corridor, checking the three reported issues:
*
* 1. LEG BOARD truthfulness — can you tell which booking rides which leg?
* 2. CONSIST DIAGRAM — does a shared wagon expose one row per load?
* 3. PARTIAL OFFER — when only the 10 PW2 are free, is the customer offered
* the part that fits instead of being dropped silently?
*
* Read-only. Run: npx ts-node -T scripts/run-legboard-tests.ts
*/
import { DataSource } from 'typeorm';
import {
planWagonsWithStock,
type AllowedWagonTypeMap,
type WagonStock,
} from '../src/modules/train-scheduling/wagon-plan-flex.util';
import { validateWagonCargoExclusivity } from '../src/modules/train-scheduling/utils/wagon-plan.util';
import { sizePartialOfferWagons, bulkTonsPerWagon } from '../src/modules/train-scheduling/train-capacity.util';
import type { Booking } from '../src/modules/bookings/entities/booking.entity';
import type { WagonType } from '../src/modules/wagon-types/entities/wagon-type.entity';
const YARD = {
DCT: 'fc558b95-da28-4fc3-8348-311a290c34ae',
DIRE: 'f7b1686f-d43e-42aa-bc6a-d3d5849296c9',
GMP: '61ae1e66-c229-4dcd-9851-b2b9424f3a95',
};
const STOPS = [YARD.DCT, YARD.DIRE, YARD.GMP];
const STOP_NAME = ['DCT', 'DIRE', 'GMP'];
const NW5 = '8f717b09-eec1-46ad-be3d-2dc0a56e55e7';
const PW2 = '8eec3a7d-8482-4397-96b6-59a028210722';
const PERISHABLE = 'a5991d3a-d690-4b7e-98fd-ea3333aa16e7';
const FT40 = '349072e7-8a90-4c03-b682-08976abfd7e8';
const ds = new DataSource({
type: 'postgres',
host: process.env.DB_HOST || '10.18.7.207',
port: Number(process.env.DB_PORT || 5432),
database: process.env.DB_NAME || 'edr_dev',
username: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'dcba@1234',
});
type Check = { name: string; pass: boolean; detail: string };
const groups: Array<{ scenario: string; checks: Check[] }> = [];
const add = (l: Check[], name: string, pass: boolean, detail: string) =>
l.push({ name, pass, detail });
let TYPES: WagonType[] = [];
let ALLOWED: AllowedWagonTypeMap;
let CODES = new Map<string, string>();
let CARGO: any;
async function loadConfig() {
TYPES = await ds.query(
`SELECT id, code, name, capacity_tons AS "capacityTons",
length_meters AS "lengthMeters", tare_weight_tons AS "tareWeightTons",
supports_container AS "supportsContainer", is_active AS "isActive"
FROM freight.wagon_types WHERE is_active IS NOT FALSE`);
const byId = new Map(TYPES.map((t) => [t.id, t]));
CODES = new Map(TYPES.map((t) => [t.id, t.code]));
const rows = async (sql: string) => {
const r: Array<{ typeId: string; wagonTypeId: string }> = await ds.query(sql);
const m = new Map<string, WagonType[]>();
for (const x of r) {
const wt = byId.get(x.wagonTypeId);
if (wt) m.set(x.typeId, [...(m.get(x.typeId) ?? []), wt]);
}
return m;
};
ALLOWED = {
byCargoTypeId: await rows(
`SELECT cargo_type_id AS "typeId", wagon_type_id AS "wagonTypeId" FROM freight.cargo_type_wagon_types`),
byContainerTypeId: await rows(
`SELECT container_type_id AS "typeId", wagon_type_id AS "wagonTypeId" FROM freight.container_type_wagon_types`),
};
const [c] = await ds.query(
`SELECT id, cargo_type_name AS "cargoTypeName", code, unit_of_measure AS "unitOfMeasure",
tons_per_wagon_map AS "tonsPerWagonMap", items_per_wagon_map AS "itemsPerWagonMap"
FROM freight.cargo_types WHERE id=$1`, [PERISHABLE]);
CARGO = { ...c, wagonTypes: ALLOWED.byCargoTypeId.get(PERISHABLE) ?? [] };
}
let seq = 0;
const bulk = (ref: string, tons: number, from: string, to: string, paid = false): Booking =>
({
id: `bulk-${(seq += 1)}`, reference: ref, freightType: 'BULK',
cargoTotalWeightVgm: tons, cargoTypeId: PERISHABLE, cargoType: CARGO,
originYardId: from, destinationYardId: to, bookingContainers: [],
status: paid ? 'PAID' : 'CLEARANCE_READY',
paymentStatus: paid ? 'PAID' : 'PENDING',
}) as unknown as Booking;
const cont = (ref: string, qty: number, from: string, to: string, paid = false): Booking =>
({
id: `cont-${(seq += 1)}`, reference: ref, freightType: 'CONTAINER',
cargoTotalWeightVgm: qty * 26, originYardId: from, destinationYardId: to,
status: paid ? 'PAID' : 'CLEARANCE_READY',
paymentStatus: paid ? 'PAID' : 'PENDING',
bookingContainers: [{
id: `line-${seq}`, containerTypeId: FT40, quantity: qty,
vgmPerUnitTons: 26, wagonsRequired: qty,
containerType: { id: FT40, code: '40FT', sizeFt: 40 }, units: [],
}],
}) as unknown as Booking;
const stockOf = (nw5: number, pw2: number): WagonStock => ({
mode: 'TRAIN',
remainingByTypeId: new Map([[NW5, nw5], [PW2, pw2]]),
codesByTypeId: CODES,
});
const legsOf = (bs: Booking[]) =>
new Map(bs.map((b: any) => [b.id,
{ from: STOPS.indexOf(b.originYardId), to: STOPS.indexOf(b.destinationYardId) }]));
const plan = (bs: Booking[], stock: WagonStock) =>
planWagonsWithStock({
bookings: bs, allowed: ALLOWED, stock, legs: legsOf(bs),
edgeCount: 2, stops: STOPS,
});
const byType = (p: any[]) => {
const c = new Map<string, number>();
for (const s of p) c.set(s.wagonTypeCode, (c.get(s.wagonTypeCode) ?? 0) + 1);
return [...c.entries()].map(([k, n]) => `${n}x ${k}`).join(', ') || 'none';
};
/**
* What the LEG BOARD can actually say about a slot, given only the fields it
* reads (boardYardId / alightYardId) — this is the UI's own view.
*/
const stampSpan = (slot: any, bs: Booking[]) => {
const byId = new Map(bs.map((b: any) => [b.id, b]));
const sb = [...new Set(slot.allocations.map((a: any) => a.bookingId))]
.map((id) => byId.get(id as string)).filter(Boolean) as any[];
if (!sb.length) return null;
const first = sb[0];
const same = sb.every((b) =>
b.originYardId === first.originYardId && b.destinationYardId === first.destinationYardId);
if (same) {
return {
from: STOPS.indexOf(first.originYardId),
to: STOPS.indexOf(first.destinationYardId),
union: false,
};
}
let from = Infinity, to = -Infinity;
for (const b of sb) {
from = Math.min(from, STOPS.indexOf(b.originYardId));
to = Math.max(to, STOPS.indexOf(b.destinationYardId));
}
return { from, to, union: true };
};
async function main() {
await ds.initialize();
await loadConfig();
console.log('\n=== REAL CONSIST (S-2026-00045): 42 NW5 + 10 PW2 ===');
console.log('Corridor: DCT -> DIRE -> GMP (edge 0 = DCT->DIRE, edge 1 = DIRE->GMP)\n');
// ============================================================ SCENARIO 1
// The reported shape: 42x40ft intercity Dire->GMP + bulk DCT->GMP.
{
const checks: Check[] = [];
const bs = [cont('IC-42', 42, YARD.DIRE, YARD.GMP), bulk('BULK-695', 695, YARD.DCT, YARD.GMP, true)];
const r = plan(bs, stockOf(42, 10));
console.log('── S1: 42x40ft Dire->GMP (intercity) + 695T bulk DCT->GMP (paid)');
console.log(` plan ${r.plan.length} wagons — ${byType(r.plan)}`);
for (const d of r.deferred) console.log(` DEFERRED ${d.reference}: ${d.reason}`);
// Every slot must say, truthfully, which legs it is busy on.
const shared = r.plan.filter((s) => {
const refs = new Set(s.allocations.map((a) => a.bookingReference));
return refs.size > 1;
});
add(checks, 'shared wagons expose EVERY load (diagram can stack them)',
shared.every((s) => s.allocations.length >= 2),
`${shared.length} shared wagon(s); allocations per shared wagon: ${
shared.map((s) => s.allocations.length).join(',') || 'n/a'}`);
// The leg-board reads ONE span per slot. For a mixed-corridor wagon that
// span is the UNION, which cannot say which load rides which leg.
const mixed = r.plan.map((s) => stampSpan(s, bs)).filter((x) => x?.union);
add(checks, 'no wagon needs a UNION span (leg board stays truthful)',
mixed.length === 0,
mixed.length
? `${mixed.length} wagon(s) carry different corridors -> leg board shows one merged bar`
: 'every wagon carries a single corridor');
add(checks, 'per-edge exclusivity holds',
validateWagonCargoExclusivity(r.plan, legsOf(bs), 2).length === 0, 'clean');
groups.push({ scenario: 'S1 — intercity 42x40ft + paid bulk (the reported case)', checks });
}
// ============================================================ SCENARIO 2
// Only the 10 PW2 are free — the staging complaint.
{
const checks: Check[] = [];
const b = bulk('BULK-695', 695, YARD.DCT, YARD.GMP, true);
const r = plan([b], stockOf(0, 10));
console.log('\n── S2: 695T bulk, ONLY 10 PW2 free (0 NW5)');
console.log(` plan ${r.plan.length} wagons — ${byType(r.plan)}`);
for (const d of r.deferred) console.log(` DEFERRED ${d.reference}: ${d.reason}`);
add(checks, 'whole booking correctly refused (10 PW2 x 20T = 200T < 695T)',
r.deferred.length === 1 && r.fitting.length === 0,
r.deferred[0]?.reason ?? 'unexpectedly fitted');
// What SHOULD happen: offer the part that fits on those 10 PW2.
const perWagonTons = bulkTonsPerWagon(CARGO, PW2, 70);
const pw2Type = TYPES.find((t) => t.id === PW2)!;
const wholeWagons = Math.ceil(695 / perWagonTons);
const offer = sizePartialOfferWagons(
{ wagons: 10, weightTons: 3500, lengthMeters: 1520 },
wholeWagons,
{ capacityTons: perWagonTons,
tareWeightTons: Number(pw2Type.tareWeightTons),
lengthMeters: Number(pw2Type.lengthMeters) },
{ fullWagonsOnly: true },
);
console.log(` partial offer sizing: ${offer ? `${offer.wagons} PW2 = ${offer.maxCargoTons}T of 695T` : 'NONE'}`);
add(checks, 'a partial offer of 10 PW2 (200T) is sizeable for the customer',
offer != null && offer.wagons === 10 && offer.maxCargoTons === 200,
offer ? `${offer.wagons} PW2 x ${perWagonTons}T = ${offer.maxCargoTons}T` : 'no offer could be sized');
groups.push({ scenario: 'S2 — only 10 PW2 free: split offer instead of silent drop', checks });
}
// ============================================================ SCENARIO 3-10
const cases: Array<{ name: string; bookings: Booking[]; stock: WagonStock; expectFit?: string[] }> = [
{ name: 'S3 — bulk DCT->DIRE + bulk DIRE->GMP (disjoint, reuse expected)',
bookings: [bulk('B-L1', 200, YARD.DCT, YARD.DIRE), bulk('B-L2', 200, YARD.DIRE, YARD.GMP)],
stock: stockOf(7, 0) },
{ name: 'S4 — container DCT->DIRE then bulk DIRE->GMP on same wagons',
bookings: [cont('C-L1', 7, YARD.DCT, YARD.DIRE), bulk('B-L2', 210, YARD.DIRE, YARD.GMP)],
stock: stockOf(7, 0) },
{ name: 'S5 — three corridors at once (DCT->DIRE, DIRE->GMP, DCT->GMP)',
bookings: [cont('C-A', 5, YARD.DCT, YARD.DIRE), cont('C-B', 5, YARD.DIRE, YARD.GMP),
bulk('B-FULL', 150, YARD.DCT, YARD.GMP, true)],
stock: stockOf(12, 10) },
{ name: 'S6 — paid bulk vs unpaid containers, scarce NW5',
bookings: [cont('C-UNPAID', 20, YARD.DCT, YARD.GMP), bulk('B-PAID', 300, YARD.DCT, YARD.GMP, true)],
stock: stockOf(20, 10) },
{ name: 'S7 — two paid bulks competing for the same PW2',
bookings: [bulk('B-P1', 200, YARD.DCT, YARD.GMP, true), bulk('B-P2', 200, YARD.DCT, YARD.GMP, true)],
stock: stockOf(5, 10) },
{ name: 'S8 — bulk exactly filling the PW2 (200T)',
bookings: [bulk('B-EXACT', 200, YARD.DCT, YARD.GMP, true)],
stock: stockOf(0, 10) },
{ name: 'S9 — 1T over the PW2 capacity (201T)',
bookings: [bulk('B-OVER', 201, YARD.DCT, YARD.GMP, true)],
stock: stockOf(0, 10) },
{ name: 'S10 — full train: containers both legs + bulk through',
bookings: [cont('C-1', 42, YARD.DCT, YARD.DIRE), cont('C-2', 42, YARD.DIRE, YARD.GMP),
bulk('B-THRU', 200, YARD.DCT, YARD.GMP, true)],
stock: stockOf(42, 10) },
];
for (const c of cases) {
const checks: Check[] = [];
const r = plan(c.bookings, c.stock);
const legs = legsOf(c.bookings);
console.log(`\n── ${c.name}`);
console.log(` plan ${r.plan.length} wagons — ${byType(r.plan)}`);
console.log(` fitting: ${r.fitting.map((b) => b.reference).join(', ') || 'none'}`);
for (const d of r.deferred) console.log(` DEFERRED ${d.reference}: ${d.reason}`);
for (const s of r.plan.filter((x) => x.allocations.length > 1)) {
const spans = s.allocations.map((a) => {
const l = legs.get(a.bookingId)!;
return `${a.bookingReference}[${STOP_NAME[l.from]}->${STOP_NAME[l.to]}]`;
});
console.log(` wagon #${s.sequenceNo} ${s.wagonTypeCode}: ${spans.join(' + ')}`);
}
add(checks, 'per-edge cargo exclusivity holds',
validateWagonCargoExclusivity(r.plan, legs, 2).length === 0,
validateWagonCargoExclusivity(r.plan, legs, 2).join('; ') || 'clean');
add(checks, 'every planned wagon carries at least one allocation',
r.plan.every((s) => s.allocations.length > 0),
`${r.plan.filter((s) => !s.allocations.length).length} empty slot(s)`);
// Per-EDGE cap: a wagon reused on two disjoint legs carries its cap on
// each leg, so assignedWeightTons (the whole-journey sum) may exceed one
// leg's cap legitimately. Check the heaviest single leg instead.
const overCap = r.plan.filter((s) => s.slotLoadType === 'BULK').filter((s) => {
const cap = bulkTonsPerWagon(CARGO, s.wagonTypeId, Number(s.capacityTons));
const perEdge = [0, 0];
for (const a of s.allocations) {
const l = legs.get(a.bookingId)!;
for (let e = l.from; e < l.to; e += 1) perEdge[e] += a.allocatedWeightTons;
}
return Math.max(...perEdge) > cap + 0.001;
});
add(checks, 'no bulk wagon exceeds its cargo cap on any single leg',
overCap.length === 0,
overCap.length ? `${overCap.length} wagon(s) over cap` : 'caps respected per leg');
// Leg-board truthfulness for every shared wagon.
// The leg board no longer relies on the slot's union span: each allocation
// carries its booking's own yards, so the UI splits a shared wagon into one
// bar per corridor. Assert that data IS derivable for every shared wagon.
const unionSpans = r.plan.map((s) => stampSpan(s, c.bookings)).filter((x) => x?.union);
const splittable = r.plan
.filter((s) => s.allocations.length > 1)
.every((s) => {
const corridors = new Set(
s.allocations.map((a) => {
const l = legs.get(a.bookingId);
return l ? `${l.from}-${l.to}` : 'unknown';
}),
);
return !corridors.has('unknown');
});
add(checks, 'leg board can name the leg of every load (per-allocation corridors)',
splittable,
unionSpans.length
? `${unionSpans.length} wagon(s) span a union — UI splits them into ${
[...new Set(r.plan.flatMap((s) => s.allocations.map((a) => {
const l = legs.get(a.bookingId)!;
return `${STOP_NAME[l.from]}->${STOP_NAME[l.to]}`;
})))].join(' | ')} bars`
: 'no shared wagons; spans already exact');
groups.push({ scenario: c.name, checks });
}
console.log('\n\n================ REPORT ================');
let pass = 0, fail = 0;
for (const g of groups) {
console.log(`\n${g.scenario}`);
for (const c of g.checks) {
console.log(` ${c.pass ? 'PASS' : 'FAIL'} ${c.name}\n ${c.detail}`);
c.pass ? (pass += 1) : (fail += 1);
}
}
console.log(`\n----------------------------------------`);
console.log(`TOTAL: ${pass} passed, ${fail} failed`);
await ds.destroy();
}
main().catch((e) => { console.error(e); process.exit(1); });

View File

@@ -0,0 +1,236 @@
/**
* Reproduces the reported S-2026-00045 failure: three DCT->Dire container
* bookings + one Dire->GMP container booking are allocated, but the
* DCT->GMP 695T bulk booking is never selected even though PW2 and NW5
* wagons are free.
*
* Runs the REAL planner against the REAL 42 NW5 + 10 PW2 consist, in several
* booking orders (= different window cycles / arrival orders).
*
* npx ts-node -T scripts/run-s45-scenario.ts
*/
import { DataSource } from 'typeorm';
import {
planWagonsWithStock,
type AllowedWagonTypeMap,
type WagonStock,
} from '../src/modules/train-scheduling/wagon-plan-flex.util';
import { validateWagonCargoExclusivity } from '../src/modules/train-scheduling/utils/wagon-plan.util';
import type { Booking } from '../src/modules/bookings/entities/booking.entity';
import type { WagonType } from '../src/modules/wagon-types/entities/wagon-type.entity';
const YARD = {
DCT: 'fc558b95-da28-4fc3-8348-311a290c34ae',
DIRE: 'f7b1686f-d43e-42aa-bc6a-d3d5849296c9',
GMP: '61ae1e66-c229-4dcd-9851-b2b9424f3a95',
};
const STOPS = [YARD.DCT, YARD.DIRE, YARD.GMP];
const NW5 = '8f717b09-eec1-46ad-be3d-2dc0a56e55e7';
const PW2 = '8eec3a7d-8482-4397-96b6-59a028210722';
const TRAIN_ID = '56eec969-55ec-4bc5-aad6-f4b7b6d17292';
const ds = new DataSource({
type: 'postgres',
host: process.env.DB_HOST || '10.18.7.207',
port: Number(process.env.DB_PORT || 5432),
database: process.env.DB_NAME || 'edr_dev',
username: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'dcba@1234',
});
async function loadConfig(): Promise<{
allowed: AllowedWagonTypeMap;
codes: Map<string, string>;
}> {
const types: WagonType[] = await ds.query(
`SELECT id, code, name, capacity_tons AS "capacityTons",
length_meters AS "lengthMeters", tare_weight_tons AS "tareWeightTons",
supports_container AS "supportsContainer", is_active AS "isActive"
FROM freight.wagon_types WHERE is_active IS NOT FALSE`,
);
const byId = new Map(types.map((t) => [t.id, t]));
const rows = async (sql: string) => {
const r: Array<{ typeId: string; wagonTypeId: string }> = await ds.query(sql);
const map = new Map<string, WagonType[]>();
for (const row of r) {
const wt = byId.get(row.wagonTypeId);
if (wt) map.set(row.typeId, [...(map.get(row.typeId) ?? []), wt]);
}
return map;
};
return {
allowed: {
byCargoTypeId: await rows(
`SELECT cargo_type_id AS "typeId", wagon_type_id AS "wagonTypeId" FROM freight.cargo_type_wagon_types`),
byContainerTypeId: await rows(
`SELECT container_type_id AS "typeId", wagon_type_id AS "wagonTypeId" FROM freight.container_type_wagon_types`),
},
codes: new Map(types.map((t) => [t.id, t.code])),
};
}
async function loadBookings(allowed: AllowedWagonTypeMap): Promise<Map<string, Booking>> {
const rows = await ds.query(
`SELECT b.id, b.reference, b.freight_type AS "freightType",
b.cargo_total_weight_vgm AS "cargoTotalWeightVgm",
b.bulk_total_weight_tons AS "bulkTotalWeightTons",
b.cargo_type_id AS "cargoTypeId", b.origin_yard_id AS "originYardId",
b.destination_yard_id AS "destinationYardId",
b.is_government AS "isGovernment", b.priority_score AS "priorityScore",
b.status, b.payment_status AS "paymentStatus",
b.created_at AS "createdAt"
FROM freight.bookings b
WHERE b.reference LIKE 'SF45-%' AND b.deleted_at IS NULL`);
const cargo = await ds.query(
`SELECT id, cargo_type_name AS "cargoTypeName", code,
unit_of_measure AS "unitOfMeasure",
tons_per_wagon_map AS "tonsPerWagonMap",
items_per_wagon_map AS "itemsPerWagonMap" FROM freight.cargo_types`);
const cargoById = new Map(cargo.map((c: any) => [c.id, c]));
const lines = await ds.query(
`SELECT bc.id, bc.booking_id AS "bookingId", bc.container_type_id AS "containerTypeId",
bc.quantity, bc.vgm_per_unit_tons AS "vgmPerUnitTons",
bc.wagons_required AS "wagonsRequired", ct.code, ct.size_ft AS "sizeFt"
FROM freight.booking_container bc
JOIN freight.container_types ct ON ct.id = bc.container_type_id
WHERE bc.booking_id = ANY($1) AND bc.deleted_at IS NULL`,
[rows.map((r: any) => r.id)]);
return new Map(rows.map((r: any) => {
const ct = r.cargoTypeId ? cargoById.get(r.cargoTypeId) : null;
return [r.reference, {
...r,
cargoType: ct
? { ...ct, wagonTypes: allowed.byCargoTypeId.get(r.cargoTypeId) ?? [] }
: null,
bookingContainers: lines.filter((l: any) => l.bookingId === r.id).map((l: any) => ({
...l,
containerType: { id: l.containerTypeId, code: l.code, sizeFt: l.sizeFt },
units: [],
})),
} as unknown as Booking];
}));
}
/** The train's real consist, as TRAIN-mode stock. */
async function consistStock(codes: Map<string, string>): Promise<WagonStock> {
const rows = await ds.query(
`SELECT wagon_type_id AS "wagonTypeId", count(*)::int AS n
FROM freight.wagons
WHERE train_id = $1 AND deleted_at IS NULL
GROUP BY wagon_type_id`, [TRAIN_ID]);
return {
mode: 'TRAIN',
remainingByTypeId: new Map(rows.map((r: any) => [r.wagonTypeId, r.n])),
codesByTypeId: codes,
};
}
const planByType = (plan: any[]) => {
const c = new Map<string, number>();
for (const s of plan) c.set(s.wagonTypeCode, (c.get(s.wagonTypeCode) ?? 0) + 1);
return [...c.entries()].map(([k, n]) => `${n}x ${k}`).join(', ') || 'none';
};
/** Gross tons riding each corridor edge — the locomotive pull check. */
const perEdgeGross = (plan: any[], legs: Map<string, { from: number; to: number }>) => {
const edges = [0, 0];
for (const slot of plan) {
const spans = slot.allocations.map((a: any) => legs.get(a.bookingId) ?? { from: 0, to: 2 });
const from = Math.min(...spans.map((s: any) => s.from));
const to = Math.max(...spans.map((s: any) => s.to));
for (let e = from; e < to; e += 1) {
edges[e] += Number(slot.tareWeightTons) + Number(slot.assignedWeightTons);
}
}
return edges.map((t) => Math.round(t));
};
async function main() {
await ds.initialize();
const { allowed, codes } = await loadConfig();
const byRef = await loadBookings(allowed);
const stock = await consistStock(codes);
console.log('\n=== S-2026-00045 CONSIST (real) ===');
console.table([...stock.remainingByTypeId.entries()].map(([id, n]) => ({
wagonType: codes.get(id), wagons: n })));
console.log('Route: DCT -> DIRE_DAWA -> GMP (2 edges) Loco pull: 3500T / 1520m\n');
const ORDERS: Array<{ name: string; refs: string[] }> = [
{ name: '1. STAGING ORDER (containers first, bulk last)',
refs: ['SF45-5', 'SF45-3', 'SF45-2', 'SF45-1', 'SF45-4'] },
{ name: '2. BULK FIRST (bulk books earliest window)',
refs: ['SF45-4', 'SF45-1', 'SF45-2', 'SF45-3', 'SF45-5'] },
{ name: '3. BULK IN THE MIDDLE',
refs: ['SF45-1', 'SF45-2', 'SF45-4', 'SF45-3', 'SF45-5'] },
{ name: '4. SEQUENTIAL (as listed by the user)',
refs: ['SF45-1', 'SF45-2', 'SF45-3', 'SF45-4', 'SF45-5'] },
{ name: '5. BULK ONLY (nothing competing)',
refs: ['SF45-4'] },
{ name: '6. BULK + the Dire->GMP leg only',
refs: ['SF45-5', 'SF45-4'] },
];
const summary: Array<Record<string, unknown>> = [];
// Second pass models the staging reality: the bulk booking is PAID (the
// customer's money is already taken) while the containers are not.
const PAID_BULK = process.env.PAID_BULK === '1';
for (const order of ORDERS) {
const bookings = order.refs.map((r) => {
const b = byRef.get(r)!;
if (!b) return b;
return PAID_BULK && r === 'SF45-4'
? ({ ...b, status: 'PAID', paymentStatus: 'PAID' } as Booking)
: b;
}).filter(Boolean);
const legs = new Map(bookings.map((b: any) => [
b.id,
{ from: STOPS.indexOf(b.originYardId), to: STOPS.indexOf(b.destinationYardId) },
]));
const result = planWagonsWithStock({
bookings, allowed,
stock: {
...stock,
remainingByTypeId: new Map(stock.remainingByTypeId),
},
legs, edgeCount: 2, stops: STOPS,
});
const refOf = (id: string) =>
bookings.find((b: any) => b.id === id)?.reference ?? id;
const bulkFitted = result.fitting.some((b) => b.reference === 'SF45-4');
const bulkDeferred = result.deferred.find((d) => d.reference === 'SF45-4');
const gross = perEdgeGross(result.plan, legs);
const excl = validateWagonCargoExclusivity(result.plan, legs, 2);
console.log(`\n──────── ${order.name} ────────`);
console.log(`order: ${order.refs.join(' -> ')}`);
console.log(`plan: ${result.plan.length} wagons — ${planByType(result.plan)}`);
console.log(`gross per edge: DCT->Dire ${gross[0]}T | Dire->GMP ${gross[1]}T (limit 3500T)`);
console.log(`fitting: ${result.fitting.map((b) => b.reference).join(', ') || 'none'}`);
for (const d of result.deferred) console.log(`DEFERRED: ${d.reference}${d.reason}`);
if (excl.length) console.log(`EXCLUSIVITY VIOLATION: ${excl.join('; ')}`);
const bulkSlots = result.plan.filter((s) =>
s.allocations.some((a) => refOf(a.bookingId) === 'SF45-4'));
if (bulkSlots.length) {
console.log(` bulk seated on: ${planByType(bulkSlots)}`);
}
summary.push({
order: order.name.slice(0, 34),
wagons: result.plan.length,
'bulk SF45-4': bulkFitted ? 'SELECTED' : 'NOT SELECTED',
deferred: result.deferred.map((d) => d.reference).join(',') || '-',
reason: bulkDeferred ? bulkDeferred.reason.slice(0, 60) : '-',
});
}
console.log('\n\n================ SUMMARY ================');
console.table(summary);
await ds.destroy();
}
main().catch((e) => { console.error(e); process.exit(1); });

View File

@@ -0,0 +1,362 @@
/**
* Runs the REAL planner (planWagonsWithStock) against the WGT-* bookings and
* the REAL wagon stock standing in edr_dev. Read-only: it plans, asserts, and
* reports — it writes nothing.
*
* npx ts-node -T scripts/run-wagon-gate-tests.ts
*/
import { DataSource } from 'typeorm';
import { planWagonsWithStock } from '../src/modules/train-scheduling/wagon-plan-flex.util';
import type { AllowedWagonTypeMap, WagonStock } from '../src/modules/train-scheduling/wagon-plan-flex.util';
import { validateWagonCargoExclusivity } from '../src/modules/train-scheduling/utils/wagon-plan.util';
import type { Booking } from '../src/modules/bookings/entities/booking.entity';
import type { WagonType } from '../src/modules/wagon-types/entities/wagon-type.entity';
const YARD = {
DCT: 'fc558b95-da28-4fc3-8348-311a290c34ae',
DIRE: 'f7b1686f-d43e-42aa-bc6a-d3d5849296c9',
KALITY: '61ae1e66-c229-4dcd-9851-b2b9424f3a95',
};
const ds = new DataSource({
type: 'postgres',
host: process.env.DB_HOST || '10.18.7.207',
port: Number(process.env.DB_PORT || 5432),
database: process.env.DB_NAME || 'edr_dev',
username: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'dcba@1234',
});
type Check = { name: string; pass: boolean; detail: string };
const results: Array<{ testCase: string; checks: Check[] }> = [];
const check = (list: Check[], name: string, pass: boolean, detail: string) => {
list.push({ name, pass, detail });
};
/** Wagon types keyed by id, and the allowed-type map read from the join tables. */
async function loadConfig(): Promise<{
allowed: AllowedWagonTypeMap;
byId: Map<string, WagonType>;
codes: Map<string, string>;
}> {
const types: WagonType[] = await ds.query(
`SELECT id, code, name, capacity_tons AS "capacityTons",
length_meters AS "lengthMeters", tare_weight_tons AS "tareWeightTons",
supports_container AS "supportsContainer", is_active AS "isActive"
FROM freight.wagon_types WHERE is_active IS NOT FALSE`,
);
const byId = new Map(types.map((t) => [t.id, t]));
const codes = new Map(types.map((t) => [t.id, t.code]));
const cargoRows: Array<{ typeId: string; wagonTypeId: string }> = await ds.query(
`SELECT cargo_type_id AS "typeId", wagon_type_id AS "wagonTypeId"
FROM freight.cargo_type_wagon_types`,
);
const containerRows: Array<{ typeId: string; wagonTypeId: string }> = await ds.query(
`SELECT container_type_id AS "typeId", wagon_type_id AS "wagonTypeId"
FROM freight.container_type_wagon_types`,
);
const collect = (rows: Array<{ typeId: string; wagonTypeId: string }>) => {
const map = new Map<string, WagonType[]>();
for (const row of rows) {
const wt = byId.get(row.wagonTypeId);
if (!wt) continue;
map.set(row.typeId, [...(map.get(row.typeId) ?? []), wt]);
}
return map;
};
return {
allowed: {
byCargoTypeId: collect(cargoRows),
byContainerTypeId: collect(containerRows),
},
byId,
codes,
};
}
/** The WGT-* bookings, hydrated the way the planner expects them. */
async function loadBookings(refs: string[]): Promise<Booking[]> {
const rows = await ds.query(
`SELECT b.id, b.reference, b.freight_type AS "freightType",
b.cargo_total_weight_vgm AS "cargoTotalWeightVgm",
b.bulk_total_weight_tons AS "bulkTotalWeightTons",
b.cargo_type_id AS "cargoTypeId",
b.origin_yard_id AS "originYardId",
b.destination_yard_id AS "destinationYardId",
b.is_government AS "isGovernment", b.priority_score AS "priorityScore",
b.created_at AS "createdAt"
FROM freight.bookings b
WHERE b.reference = ANY($1) AND b.deleted_at IS NULL`,
[refs],
);
const cargoTypes = await ds.query(
`SELECT ct.id, ct.cargo_type_name AS "cargoTypeName", ct.code,
ct.unit_of_measure AS "unitOfMeasure",
ct.tons_per_wagon_map AS "tonsPerWagonMap",
ct.items_per_wagon_map AS "itemsPerWagonMap"
FROM freight.cargo_types ct`,
);
const cargoById = new Map(cargoTypes.map((c: any) => [c.id, c]));
const { allowed } = await loadConfig();
const lines = await ds.query(
`SELECT bc.id, bc.booking_id AS "bookingId", bc.container_type_id AS "containerTypeId",
bc.quantity, bc.vgm_per_unit_tons AS "vgmPerUnitTons",
bc.wagons_required AS "wagonsRequired", bc.container_number AS "containerNumber",
ct.code, ct.size_ft AS "sizeFt"
FROM freight.booking_container bc
JOIN freight.container_types ct ON ct.id = bc.container_type_id
WHERE bc.booking_id = ANY($1) AND bc.deleted_at IS NULL`,
[rows.map((r: any) => r.id)],
);
return rows.map((r: any) => {
const cargoType = r.cargoTypeId ? cargoById.get(r.cargoTypeId) : null;
return {
...r,
cargoType: cargoType
? { ...cargoType, wagonTypes: allowed.byCargoTypeId.get(r.cargoTypeId) ?? [] }
: null,
bookingContainers: lines
.filter((l: any) => l.bookingId === r.id)
.map((l: any) => ({
...l,
containerType: { id: l.containerTypeId, code: l.code, sizeFt: l.sizeFt },
units: [],
})),
} as unknown as Booking;
});
}
/** Real AVAILABLE wagons standing at a yard, by type. */
async function stockAtYard(yardId: string, codes: Map<string, string>): Promise<WagonStock> {
const rows = await ds.query(
`SELECT wagon_type_id AS "wagonTypeId", count(*)::int AS n
FROM freight.wagons
WHERE current_yard_id = $1 AND deleted_at IS NULL
AND status = 'AVAILABLE' AND train_id IS NULL
AND current_train_schedule_id IS NULL
GROUP BY wagon_type_id`,
[yardId],
);
return {
mode: 'YARD',
remainingByTypeId: new Map(rows.map((r: any) => [r.wagonTypeId, r.n])),
codesByTypeId: codes,
};
}
/** Per-type slot counts of a plan, as a readable "10x PW2, 17x NW5". */
const planByType = (plan: any[]): string => {
const counts = new Map<string, number>();
for (const slot of plan) {
counts.set(slot.wagonTypeCode, (counts.get(slot.wagonTypeCode) ?? 0) + 1);
}
return [...counts.entries()].map(([code, n]) => `${n}x ${code}`).join(', ') || 'none';
};
async function main() {
await ds.initialize();
const { allowed, codes } = await loadConfig();
const dctStock = await stockAtYard(YARD.DCT, codes);
console.log('\n=== REAL FLEET AT DORALEH_FREEZONE (AVAILABLE, unpinned) ===');
console.table(
[...dctStock.remainingByTypeId.entries()].map(([id, n]) => ({
wagonType: codes.get(id),
available: n,
})),
);
// ---------------------------------------------------------------- CASE A
{
const checks: Check[] = [];
const bookings = await loadBookings(['WGT-A1', 'WGT-A2', 'WGT-A3']);
// Constrain the fleet to make the contest real: 20 NW5 + 10 PW2.
const stock: WagonStock = {
mode: 'TRAIN',
remainingByTypeId: new Map([
['8f717b09-eec1-46ad-be3d-2dc0a56e55e7', 20], // NW5
['8eec3a7d-8482-4397-96b6-59a028210722', 10], // PW2
]),
codesByTypeId: codes,
};
const result = planWagonsWithStock({ bookings, allowed, stock });
const bulkSlots = result.plan.filter((s) => s.slotLoadType === 'BULK');
const pw2Bulk = bulkSlots.filter((s) => s.wagonTypeCode === 'PW2').length;
const nw5Bulk = bulkSlots.filter((s) => s.wagonTypeCode === 'NW5').length;
check(checks, 'bulk fills bulk-only PW2 first', pw2Bulk === 10,
`${pw2Bulk}/10 PW2 used by bulk`);
check(checks, 'PW2 bulk wagons respect the 20T cargo cap',
bulkSlots.filter((s) => s.wagonTypeCode === 'PW2')
.every((s) => s.assignedWeightTons <= 20),
`max PW2 load ${Math.max(0, ...bulkSlots.filter((s) => s.wagonTypeCode === 'PW2').map((s) => s.assignedWeightTons))}T (cap 20T)`);
check(checks, 'NW5 bulk wagons respect the 30T cargo cap',
bulkSlots.filter((s) => s.wagonTypeCode === 'NW5')
.every((s) => s.assignedWeightTons <= 30),
`max NW5 bulk load ${Math.max(0, ...bulkSlots.filter((s) => s.wagonTypeCode === 'NW5').map((s) => s.assignedWeightTons))}T (cap 30T)`);
check(checks, 'no wagon mixes bulk with other cargo',
validateWagonCargoExclusivity(result.plan).length === 0,
validateWagonCargoExclusivity(result.plan).join('; ') || 'clean');
check(checks, 'plan is honest about who fits',
result.fitting.length + result.deferred.length === 3,
`fitting=[${result.fitting.map((b) => b.reference)}] deferred=[${result.deferred.map((d) => d.reference)}]`);
results.push({ testCase: 'A — bulk vs container contest for NW5 (20 NW5 + 10 PW2)', checks });
console.log('\n=== CASE A: 695T Perishable + 12x40ft + 16x20ft, stock 20 NW5 / 10 PW2 ===');
console.log(`plan: ${result.plan.length} wagons — ${planByType(result.plan)}`);
console.log(` bulk: ${pw2Bulk}x PW2 + ${nw5Bulk}x NW5`);
console.log(`fitting: ${result.fitting.map((b) => b.reference).join(', ') || 'none'}`);
for (const d of result.deferred) console.log(`deferred: ${d.reference}${d.reason}`);
}
// ---------------------------------------------------------------- CASE B
{
const checks: Check[] = [];
const bookings = await loadBookings(['WGT-B1', 'WGT-B2']);
const stops = [YARD.DCT, YARD.DIRE, YARD.KALITY];
const legs = new Map(
bookings.map((b: any) => [
b.id,
{ from: stops.indexOf(b.originYardId), to: stops.indexOf(b.destinationYardId) },
]),
);
const stock: WagonStock = {
mode: 'TRAIN',
remainingByTypeId: new Map([
['8f717b09-eec1-46ad-be3d-2dc0a56e55e7', 3], // NW5 — only 3, forces reuse
['8eec3a7d-8482-4397-96b6-59a028210722', 0],
]),
codesByTypeId: codes,
};
const result = planWagonsWithStock({
bookings, allowed, stock, legs, edgeCount: 2, stops,
});
check(checks, 'both disjoint-leg bookings fit on 3 wagons',
result.fitting.length === 2 && result.plan.length <= 3,
`fitting=${result.fitting.length}/2, wagons=${result.plan.length}`);
const legIssues = validateWagonCargoExclusivity(result.plan, legs, 2);
check(checks, 'no wagon carries bulk + container on the SAME leg',
legIssues.length === 0, legIssues.join('; ') || 'clean');
// …and the leg-blind reading WOULD flag it, proving the reuse is real.
check(checks, 'the same wagon does carry both kinds across DIFFERENT legs',
validateWagonCargoExclusivity(result.plan).length > 0,
'leg-blind check sees bulk+container on one wagon (legal: disjoint legs)');
const reused = result.plan.filter((s) => s.allocations.length > 1);
check(checks, 'leg-disjoint reuse actually happens',
reused.length > 0,
`${reused.length} wagon(s) carry both bookings on different legs`);
results.push({ testCase: 'B — leg-disjoint reuse (container leg 1, bulk leg 2), only 3 NW5', checks });
console.log('\n=== CASE B: 6x20ft DCT->Dire + 120T bulk Dire->Kality, only 3 NW5 ===');
console.log(`plan: ${result.plan.length} wagons — ${planByType(result.plan)}`);
for (const slot of result.plan) {
console.log(` wagon #${slot.sequenceNo} (${slot.wagonTypeCode}): ${slot.allocations
.map((a) => `${a.bookingReference}/${a.loadType} ${a.allocatedWeightTons}T`).join(' + ')}`);
}
for (const d of result.deferred) console.log(`deferred: ${d.reference}${d.reason}`);
}
// ---------------------------------------------------------------- CASE C
{
const checks: Check[] = [];
const bookings = await loadBookings(['WGT-C1']);
const stock: WagonStock = {
mode: 'TRAIN',
remainingByTypeId: new Map([
['8f717b09-eec1-46ad-be3d-2dc0a56e55e7', 20], // NW5 (40T cap for steel)
['8eec3a7d-8482-4397-96b6-59a028210722', 20], // PW2 (10T cap for steel!)
]),
codesByTypeId: codes,
};
const result = planWagonsWithStock({ bookings, allowed, stock });
const nw5 = result.plan.filter((s) => s.wagonTypeCode === 'NW5').length;
const pw2 = result.plan.filter((s) => s.wagonTypeCode === 'PW2').length;
check(checks, 'inverted caps: uses the 40T NW5, not the 10T PW2',
nw5 === 10 && pw2 === 0,
`${nw5}x NW5 (40T cap) + ${pw2}x PW2 (10T cap) for 400T`);
check(checks, 'wagon count matches the cap math (400/40 = 10)',
result.plan.length === 10, `${result.plan.length} wagons`);
results.push({ testCase: 'C — cap inversion (Steel Billet: NW5 40T vs PW2 10T)', checks });
console.log('\n=== CASE C: 400T Steel Billet, caps NW5=40T PW2=10T, both in stock ===');
console.log(`plan: ${result.plan.length} wagons — ${planByType(result.plan)}`);
}
// ---------------------------------------------------------------- CASE D
{
const checks: Check[] = [];
const bookings = await loadBookings(['WGT-D1', 'WGT-D2']);
const stock = await stockAtYard(YARD.DCT, codes);
const result = planWagonsWithStock({ bookings, allowed, stock });
const d1 = result.plan.filter((s) =>
s.allocations.some((a) => a.bookingReference === 'WGT-D1'));
const d2 = result.plan.filter((s) =>
s.allocations.some((a) => a.bookingReference === 'WGT-D2'));
check(checks, 'Beans (PW2-only) is deferred — no PW2 free at DCT',
result.deferred.some((d) => d.reference === 'WGT-D1'),
result.deferred.find((d) => d.reference === 'WGT-D1')?.reason ?? `planned on ${planByType(d1)}`);
check(checks, 'Sand rides only its configured CW3/CW4',
d2.length > 0 && d2.every((s) => ['CW3', 'CW4'].includes(s.wagonTypeCode)),
`Sand on ${planByType(d2)}`);
results.push({ testCase: 'D — exclusive-type cargo against REAL DCT stock', checks });
console.log('\n=== CASE D: 200T Beans (PW2-only) + 300T Sand (CW3/CW4-only), REAL stock ===');
console.log(`plan: ${result.plan.length} wagons — ${planByType(result.plan)}`);
for (const d of result.deferred) console.log(`deferred: ${d.reference}${d.reason}`);
}
// ---------------------------------------------------------------- CASE E
{
const checks: Check[] = [];
const bookings = await loadBookings(['WGT-E1', 'WGT-E2']);
const stock: WagonStock = {
mode: 'TRAIN',
remainingByTypeId: new Map([
['8f717b09-eec1-46ad-be3d-2dc0a56e55e7', 30],
['8eec3a7d-8482-4397-96b6-59a028210722', 5],
]),
codesByTypeId: codes,
};
const result = planWagonsWithStock({ bookings, allowed, stock });
check(checks, '3000T booking is deferred, not silently truncated',
result.deferred.some((d) => d.reference === 'WGT-E1'),
result.deferred.find((d) => d.reference === 'WGT-E1')?.reason ?? 'FITTED (unexpected)');
const e2 = result.plan.filter((s) =>
s.allocations.some((a) => a.bookingReference === 'WGT-E2'));
check(checks, 'the 45T booking never shares a wagon with another booking',
e2.every((s) => new Set(s.allocations.map((a) => a.bookingId)).size === 1),
`${e2.length} wagon(s), all single-booking`);
check(checks, 'exclusivity holds across the whole plan',
validateWagonCargoExclusivity(result.plan).length === 0,
validateWagonCargoExclusivity(result.plan).join('; ') || 'clean');
results.push({ testCase: 'E — overload one leg (3000T) + small bulk beside it', checks });
console.log('\n=== CASE E: 3000T + 45T Perishable, stock 30 NW5 / 5 PW2 ===');
console.log(`plan: ${result.plan.length} wagons — ${planByType(result.plan)}`);
console.log(`fitting: ${result.fitting.map((b) => b.reference).join(', ') || 'none'}`);
for (const d of result.deferred) console.log(`deferred: ${d.reference}${d.reason}`);
}
// ---------------------------------------------------------------- REPORT
console.log('\n\n================ TEST REPORT ================');
let passed = 0;
let failed = 0;
for (const group of results) {
console.log(`\n${group.testCase}`);
for (const c of group.checks) {
console.log(` ${c.pass ? 'PASS' : 'FAIL'} ${c.name}\n ${c.detail}`);
c.pass ? (passed += 1) : (failed += 1);
}
}
console.log(`\n---------------------------------------------`);
console.log(`TOTAL: ${passed} passed, ${failed} failed`);
await ds.destroy();
process.exit(failed > 0 ? 1 : 0);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,96 @@
/**
* Seeds the S-2026-00045 reported scenario into edr_dev (idempotent).
* References SF45-* ("Scenario Fortyfive"). Rows are kept as evidence.
*
* SELECT * FROM freight.bookings WHERE reference LIKE 'SF45-%';
*/
const { Client } = require('pg');
const YARD = {
DCT: 'fc558b95-da28-4fc3-8348-311a290c34ae',
DIRE: 'f7b1686f-d43e-42aa-bc6a-d3d5849296c9',
GMP: '61ae1e66-c229-4dcd-9851-b2b9424f3a95', // KALITY = GMP (Gelan)
};
const PERISHABLE = 'a5991d3a-d690-4b7e-98fd-ea3333aa16e7'; // NW5 30T / PW2 20T
const FT40 = '349072e7-8a90-4c03-b682-08976abfd7e8';
const COMPANY = '300d5510-e3a5-4858-bc28-6e3beda8ca80';
const BOOKINGS = [
{ ref: 'SF45-1', type: 'CONTAINER', from: 'DCT', to: 'DIRE', qty: 9,
note: 'S45: DCT->Dire 9x40ft' },
{ ref: 'SF45-2', type: 'CONTAINER', from: 'DCT', to: 'DIRE', qty: 19,
note: 'S45: DCT->Dire 19x40ft' },
{ ref: 'SF45-3', type: 'CONTAINER', from: 'DCT', to: 'DIRE', qty: 19,
note: 'S45: DCT->Dire 19x40ft (second)' },
{ ref: 'SF45-4', type: 'BULK', from: 'DCT', to: 'GMP', tons: 695,
note: 'S45: DCT->GMP 695T Perishable - THE ONE NOT BEING SELECTED' },
{ ref: 'SF45-5', type: 'CONTAINER', from: 'DIRE', to: 'GMP', qty: 42,
note: 'S45: Dire->GMP 42x40ft' },
];
async function main() {
const client = new Client({
host: process.env.DB_HOST || '10.18.7.207',
port: Number(process.env.DB_PORT || 5432),
database: process.env.DB_NAME || 'edr_dev',
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'dcba@1234',
});
await client.connect();
const out = [];
for (const b of BOOKINGS) {
const isBulk = b.type === 'BULK';
const vgmPerUnit = 26;
const totalVgm = isBulk ? b.tons : b.qty * vgmPerUnit;
const existing = await client.query(
`SELECT id FROM freight.bookings WHERE reference = $1`, [b.ref]);
let id;
if (existing.rows.length) {
id = existing.rows[0].id;
await client.query(
`UPDATE freight.bookings
SET cargo_total_weight_vgm=$2, cargo_type_id=$3, origin_yard_id=$4,
destination_yard_id=$5, freight_type=$6, trade_direction='IMPORT',
status='CLEARANCE_READY', payment_status='PENDING',
scheduling_status='ELIGIBLE', train_schedule_id=NULL,
cargo_free_text=$7, updated_at=now()
WHERE id=$1`,
[id, totalVgm, isBulk ? PERISHABLE : null, YARD[b.from], YARD[b.to],
b.type, b.note]);
} else {
const res = await client.query(
`INSERT INTO freight.bookings (
reference, status, total_amount, payment_status, contract_type,
trade_direction, equipment_return, cargo_total_weight_vgm, is_hazardous,
payment_currency, version_number, priority_score, origin_yard_id,
destination_yard_id, cargo_type_id, cargo_free_text, freight_type,
scheduling_status, is_government, customs_clearing_enabled, is_reefer,
booking_type, is_split, company_id, created_at, updated_at
) VALUES ($1,'CLEARANCE_READY',0,'PENDING','NEW','IMPORT','WITHOUT_RETURN',
$2,false,'ETB',1,0,$3,$4,$5,$6,$7,'ELIGIBLE',false,false,false,
'ONE_TIME',false,$8, now(), now())
RETURNING id`,
[b.ref, totalVgm, YARD[b.from], YARD[b.to],
isBulk ? PERISHABLE : null, b.note, b.type, COMPANY]);
id = res.rows[0].id;
}
await client.query(`DELETE FROM freight.booking_container WHERE booking_id=$1`, [id]);
if (!isBulk) {
await client.query(
`INSERT INTO freight.booking_container
(booking_id, container_type_id, quantity, vgm_per_unit_tons,
total_vgm_tons, wagons_required, container_size, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,'40ft', now(), now())`,
[id, FT40, b.qty, vgmPerUnit, b.qty * vgmPerUnit, b.qty]);
}
out.push({ ref: b.ref, id, note: b.note });
}
console.table(out);
await client.end();
}
main().catch((e) => { console.error(e.message); process.exit(1); });

View File

@@ -0,0 +1,142 @@
/**
* Seeds the wagon-allocation test bookings into edr_dev (idempotent) and
* prints their ids. Rows are kept — they are the evidence for the report.
*
* References are prefixed WGT- (Wagon Gate Test) so they are easy to find:
* SELECT * FROM freight.bookings WHERE reference LIKE 'WGT-%';
*/
const { Client } = require('pg');
const YARD = {
DORALEH_FREEZONE: 'fc558b95-da28-4fc3-8348-311a290c34ae',
DIRE_DAWA: 'f7b1686f-d43e-42aa-bc6a-d3d5849296c9',
KALITY: '61ae1e66-c229-4dcd-9851-b2b9424f3a95',
DMP: '7b658678-ce2b-41aa-8dc5-b38ba4a76e9b',
};
const CARGO = {
PERISHABLE: 'a5991d3a-d690-4b7e-98fd-ea3333aa16e7', // NW5 30T / PW2 20T
STEEL_BILLET: '8291ccc3-0dd9-4d78-aa44-284d028a19ce', // NW5 40T / PW2 10T (inverted)
BEANS: '9afd8eb3-975b-4ea7-a7db-04eb2c20a04d', // PW2 only, no cap
SAND: '8942884d-9991-42bb-87f7-a70930f8c43c', // CW3/CW4 only, no cap
};
const CONTAINER = {
'20FT': '77cf24ec-e74b-4bdb-b1cc-6f336379cc58',
'40FT': '349072e7-8a90-4c03-b682-08976abfd7e8',
};
const COMPANY = '300d5510-e3a5-4858-bc28-6e3beda8ca80';
/** Test bookings: 4 corridors, bulk + container, contested and uncontested. */
const BOOKINGS = [
// --- Case A: DORALEH_FREEZONE -> KALITY (full leg), bulk vs container contest
{ ref: 'WGT-A1', dir: 'IMPORT', type: 'BULK', from: 'DORALEH_FREEZONE', to: 'KALITY',
cargo: 'PERISHABLE', tons: 695, note: 'A: 695T Perishable, contends with containers for NW5' },
{ ref: 'WGT-A2', dir: 'IMPORT', type: 'CONTAINER', from: 'DORALEH_FREEZONE', to: 'KALITY',
containers: [{ type: '40FT', qty: 12, vgm: 26 }], note: 'A: 12x40ft, needs 12 container wagons' },
{ ref: 'WGT-A3', dir: 'IMPORT', type: 'CONTAINER', from: 'DORALEH_FREEZONE', to: 'KALITY',
containers: [{ type: '20FT', qty: 16, vgm: 12 }], note: 'A: 16x20ft = 8 wagons (TEU paired)' },
// --- Case B: sub-corridor legs on the same 3-stop route (leg overlap)
{ ref: 'WGT-B1', dir: 'IMPORT', type: 'CONTAINER', from: 'DORALEH_FREEZONE', to: 'DIRE_DAWA',
containers: [{ type: '20FT', qty: 6, vgm: 14 }], note: 'B: leg 1 only (DCT->Dire), 3 wagons' },
{ ref: 'WGT-B2', dir: 'IMPORT', type: 'BULK', from: 'DIRE_DAWA', to: 'KALITY',
cargo: 'PERISHABLE', tons: 120, note: 'B: leg 2 only (Dire->Kality) - may reuse leg-1 wagons' },
// --- Case C: cap inversion - Steel Billet is CHEAPER on NW5 (40T) than PW2 (10T)
{ ref: 'WGT-C1', dir: 'IMPORT', type: 'BULK', from: 'DORALEH_FREEZONE', to: 'KALITY',
cargo: 'STEEL_BILLET', tons: 400, note: 'C: inverted caps - must NOT blindly take PW2' },
// --- Case D: exclusive-type cargo (Beans=PW2 only, Sand=CW3/CW4 only)
{ ref: 'WGT-D1', dir: 'IMPORT', type: 'BULK', from: 'DORALEH_FREEZONE', to: 'KALITY',
cargo: 'BEANS', tons: 200, note: 'D: PW2-only cargo, no alternative' },
{ ref: 'WGT-D2', dir: 'IMPORT', type: 'BULK', from: 'DORALEH_FREEZONE', to: 'KALITY',
cargo: 'SAND', tons: 300, note: 'D: CW3/CW4-only cargo' },
// --- Case E: overload one leg (way beyond any train)
{ ref: 'WGT-E1', dir: 'IMPORT', type: 'BULK', from: 'DORALEH_FREEZONE', to: 'KALITY',
cargo: 'PERISHABLE', tons: 3000, note: 'E: 3000T - must overflow the loco pull limit' },
{ ref: 'WGT-E2', dir: 'IMPORT', type: 'BULK', from: 'DORALEH_FREEZONE', to: 'KALITY',
cargo: 'PERISHABLE', tons: 45, note: 'E: small bulk - must NOT share a wagon with A1' },
];
async function main() {
const client = new Client({
host: process.env.DB_HOST || '10.18.7.207',
port: Number(process.env.DB_PORT || 5432),
database: process.env.DB_NAME || 'edr_dev',
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'dcba@1234',
});
await client.connect();
const out = [];
for (const b of BOOKINGS) {
const isBulk = b.type === 'BULK';
const totalVgm = isBulk
? b.tons
: b.containers.reduce((s, l) => s + l.qty * l.vgm, 0);
const existing = await client.query(
`SELECT id FROM freight.bookings WHERE reference = $1`,
[b.ref],
);
let id;
if (existing.rows.length) {
id = existing.rows[0].id;
await client.query(
`UPDATE freight.bookings
SET cargo_total_weight_vgm = $2, cargo_type_id = $3,
origin_yard_id = $4, destination_yard_id = $5,
freight_type = $6, trade_direction = $7,
status = 'CLEARANCE_READY', payment_status = 'PENDING',
scheduling_status = 'ELIGIBLE', train_schedule_id = NULL,
cargo_free_text = $8, updated_at = now()
WHERE id = $1`,
[id, totalVgm, isBulk ? CARGO[b.cargo] : null,
YARD[b.from], YARD[b.to], b.type, b.dir, b.note],
);
} else {
const res = await client.query(
`INSERT INTO freight.bookings (
reference, status, total_amount, payment_status, contract_type,
trade_direction, equipment_return, cargo_total_weight_vgm,
is_hazardous, payment_currency, version_number, priority_score,
origin_yard_id, destination_yard_id, cargo_type_id, cargo_free_text,
freight_type, scheduling_status, is_government,
customs_clearing_enabled, is_reefer, booking_type, is_split,
company_id, created_at, updated_at
) VALUES ($1,'CLEARANCE_READY',0,'PENDING','NEW',$2,'WITHOUT_RETURN',$3,
false,'ETB',1,0,$4,$5,$6,$7,$8,'ELIGIBLE',false,
false,false,'ONE_TIME',false,$9, now(), now())
RETURNING id`,
[b.ref, b.dir, totalVgm, YARD[b.from], YARD[b.to],
isBulk ? CARGO[b.cargo] : null, b.note, b.type, COMPANY],
);
id = res.rows[0].id;
}
// Container lines
await client.query(`DELETE FROM freight.booking_container WHERE booking_id = $1`, [id]);
if (!isBulk) {
for (const line of b.containers) {
await client.query(
`INSERT INTO freight.booking_container
(booking_id, container_type_id, quantity, vgm_per_unit_tons,
total_vgm_tons, wagons_required, container_size, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7, now(), now())`,
[id, CONTAINER[line.type], line.qty, line.vgm, line.qty * line.vgm,
line.type === '40FT' ? line.qty : Math.ceil(line.qty / 2),
line.type === '40FT' ? '40ft' : '20ft'],
);
}
}
out.push({ ref: b.ref, id, note: b.note });
}
console.table(out);
await client.end();
}
main().catch((err) => {
console.error(err.message);
process.exit(1);
});

View File

@@ -51,6 +51,7 @@ import { SupportChatModule } from "./modules/support-chat/support-chat.module";
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module";
import { OperationsReportingModule } from "./modules/operations-reporting/operations-reporting.module";
import { PaymentSettingsModule } from "./modules/payment-settings/payment-settings.module";
import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.module";
import { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module";
@@ -101,6 +102,7 @@ import { RoutesModule } from "./modules/routes/routes.module";
import { WarehousesModule } from "./modules/warehouses/warehouses.module";
import { OverviewModule } from "./modules/overview/overview.module";
import { ReportsModule } from "./modules/reports/reports.module";
import { ExportsModule } from "./modules/exports/exports.module";
import { UserTradeAccessModule } from "./modules/user-trade-access/user-trade-access.module";
import { VehiclesModule } from "./modules/vehicles/vehicles.module";
import { DriversModule } from "./modules/drivers/drivers.module";
@@ -219,6 +221,7 @@ if (!process.env.APPLICATION_NAME) {
FileUploadSettingsModule,
DropdownSettingsModule,
ExchangeSettingsModule,
OperationsReportingModule,
PaymentSettingsModule,
StampSettingsModule,
LogoSettingsModule,
@@ -239,6 +242,7 @@ if (!process.env.APPLICATION_NAME) {
WarehousesModule,
OverviewModule,
ReportsModule,
ExportsModule,
UserTradeAccessModule,
VehiclesModule,
DriversModule,

View File

@@ -1,5 +1,5 @@
import { applyDecorators, UseGuards } from '@nestjs/common';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { FreightJwtGuard } from './freight-jwt.guard';
import {
FreightPermissionGuard,
@@ -11,7 +11,7 @@ import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
export const BookingStaff = (permission: string | string[]) =>
applyDecorators(
UseGuards(
JwtGuard,
FreightJwtGuard,
FreightPermissionGuard(
Array.isArray(permission) ? permission : [permission],
),
@@ -26,11 +26,11 @@ export const BookingStaff = (permission: string | string[]) =>
* BookingStaff(<view key>) or MixedAudience(); kept for routes not yet swept.
*/
export const StaffReference = () =>
applyDecorators(UseGuards(JwtGuard, FreightPermissionGuard([])));
applyDecorators(UseGuards(FreightJwtGuard, FreightPermissionGuard([])));
/** Portal routes: customer accounts only; ownership scoping stays in services. */
export const PortalCustomer = () =>
applyDecorators(UseGuards(JwtGuard, PortalCustomerGuard));
applyDecorators(UseGuards(FreightJwtGuard, PortalCustomerGuard));
/**
* Routes both audiences call (sign, shared document reads, handover): staff
@@ -40,7 +40,7 @@ export const PortalCustomer = () =>
export const MixedAudience = (permission: string | string[]) =>
applyDecorators(
UseGuards(
JwtGuard,
FreightJwtGuard,
MixedAudienceGuard(
Array.isArray(permission) ? permission : [permission],
),
@@ -75,7 +75,7 @@ export const TrainSchedulingView = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.view);
// Granular train-scheduling actions replace the retired coarse manage:
// create a schedule, update (assign/consist/loading/finalize/dispatch/arrive…),
// create a schedule, update (assign/consist/finalize/dispatch/arrive…),
// cancel a schedule, reschedule (+ maintenance), and manage global rules.
export const TrainSchedulingCreate = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.create);
@@ -83,6 +83,36 @@ export const TrainSchedulingCreate = () =>
export const TrainSchedulingUpdate = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.update);
/**
* Confirm a booking's cargo loaded/unloaded at a yard — carved out of the
* coarse `update` so it can be granted independently of general schedule
* editing. Same two keys gate import, export, and intercity movements alike:
* the generic per-booking route and the intercity-specific one both use them.
*/
export const TrainSchedulingLoad = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.load);
export const TrainSchedulingUnload = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.unload);
/**
* Per-station loading/unloading time windows — the four buttons are four
* permissions so start and end can be granted to different people. The same
* endpoint that records a click also edits it (explicit `at`), so each
* permission covers editing its own timestamp too.
*/
export const TrainSchedulingLoadingStart = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.loadingStart);
export const TrainSchedulingLoadingEnd = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.loadingEnd);
export const TrainSchedulingUnloadingStart = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.unloadingStart);
export const TrainSchedulingUnloadingEnd = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.unloadingEnd);
export const TrainSchedulingCancel = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.cancel);

View File

@@ -0,0 +1,57 @@
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { FilterBookingDto } from '../../modules/bookings/dto/filter-booking.dto';
import { ListTrainSchedulesQueryDto } from '../../modules/train-scheduling/dto/list-train-schedules-query.dto';
/**
* The route filters carry one id, `a,b`, or a repeated param, and the
* repositories then branch on `?.length` before emitting `IN (:...ids)`.
* Two things have to hold or that breaks at runtime, not compile time:
* the value must always arrive as an array (a bare string would make
* `.length` count characters), and an absent/blank param must arrive as
* `undefined`, never `[]` — TypeORM turns `[]` into the syntax error `IN ()`.
*/
// Real-shaped v4s: the variant nibble must be 8/9/a/b, so `1111…` is NOT a
// valid UUID and would fail `@IsUUID` for reasons that have nothing to do
// with the list transform under test.
const A = '0a5d4b1e-1b2c-4d3e-8f90-1234567890ab';
const B = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
const parse = <T>(cls: new () => T, query: Record<string, unknown>): T =>
plainToInstance(cls, query);
describe('route id-list query params', () => {
it('accepts a single id, still as an array', () => {
const dto = parse(FilterBookingDto, { originYardId: A });
expect(dto.originYardId).toEqual([A]);
expect(validateSync(dto)).toHaveLength(0);
});
it('splits a comma-separated list', () => {
const dto = parse(FilterBookingDto, { originYardId: `${A}, ${B}` });
expect(dto.originYardId).toEqual([A, B]);
expect(validateSync(dto)).toHaveLength(0);
});
it('accepts the repeated-param form', () => {
const dto = parse(ListTrainSchedulesQueryDto, { destinationStationId: [A, B] });
expect(dto.destinationStationId).toEqual([A, B]);
expect(validateSync(dto)).toHaveLength(0);
});
it.each([undefined, '', ','])('yields undefined, never [], for %p', (raw) => {
expect(parse(FilterBookingDto, { originYardId: raw }).originYardId).toBeUndefined();
});
it('leaves the two ends independent — one side set, the other absent', () => {
const dto = parse(FilterBookingDto, { originYardId: A });
expect(dto.originYardId).toEqual([A]);
expect(dto.destinationYardId).toBeUndefined();
});
it('still rejects a non-uuid inside the list', () => {
const dto = parse(FilterBookingDto, { originYardId: `${A},not-a-uuid` });
expect(validateSync(dto)).not.toHaveLength(0);
});
});

View File

@@ -0,0 +1,29 @@
import { Transform } from 'class-transformer';
/**
* A query param that carries one id, a comma-separated list (`a,b,c`), or the
* same key repeated — and always lands on the DTO as a `string[]`.
*
* Two details matter:
*
* - It yields `undefined`, never `[]`, when nothing usable is left. `@IsOptional`
* then short-circuits, and — more importantly — a repository that does
* `if (ids?.length)` can never be handed an empty array, which TypeORM turns
* into the syntax error `IN ()`.
* - It is backwards compatible with the single-value form these params used to
* take, so existing deep links and saved views keep working unchanged.
*
* Pair it with `@IsUUID(undefined, { each: true })` (or the relevant `each`
* validator) — this only reshapes the value, it does not validate it.
*/
export const IdListParam = () =>
Transform(({ value }: { value: unknown }) => {
const raw = Array.isArray(value) ? value : [value];
const ids = raw
.flatMap((entry) =>
entry === undefined || entry === null ? [] : String(entry).split(','),
)
.map((s) => s.trim())
.filter(Boolean);
return ids.length ? ids : undefined;
});

View File

@@ -0,0 +1,32 @@
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { PaginationQueryDto } from './pagination-query.dto';
import { ListWagonsQueryDto } from '../../modules/wagons/dto/list-wagons-query.dto';
import { normalizePagination } from '../utils/pagination.util';
/**
* The page-size ceiling is stated in three places that must agree: `@Max` on
* PaginationQueryDto, the same `@Max` repeated on ListWagonsQueryDto (which
* doesn't extend it), and `MAX_PAGE_SIZE` in pagination.util. A fourth copy
* lives outside this package — `MAX_PAGE_SIZE` in @edr/ui-common's data-table
* footer, which is what actually asks for the number. Drift between any of
* them shows up as a 400 on the largest rows-per-page option, so pin them.
*/
const errorsFor = (cls: any, pageSize: unknown) =>
validateSync(plainToInstance(cls, { pageSize }), { whitelist: false });
describe('page size ceiling', () => {
it.each([PaginationQueryDto, ListWagonsQueryDto])('accepts 500 on %p', (cls) => {
expect(errorsFor(cls, 500)).toHaveLength(0);
});
it.each([PaginationQueryDto, ListWagonsQueryDto])('rejects 501 on %p', (cls) => {
expect(errorsFor(cls, 501)).not.toHaveLength(0);
});
it('does not truncate 500 in the service-side clamp', () => {
expect(normalizePagination({ page: 1, pageSize: 500 }).take).toBe(500);
expect(normalizePagination({ page: 1, pageSize: 501 }).take).toBe(500);
});
});

View File

@@ -19,12 +19,18 @@ export class PaginationQueryDto {
@Min(1)
page?: number;
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
/**
* Ceiling is 500, matching `MAX_PAGE_SIZE` in `common/utils/pagination.util.ts`
* and the backoffice table footer's largest option. The three have to agree:
* a lower value here turns the footer's top preset into a 400, a higher one
* lets a request through that the util then silently truncates.
*/
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 500 })
@IsOptional()
@Transform(({ value }) => parseInt(String(value), 10) || 20)
@IsInt()
@Min(1)
@Max(100)
@Max(500)
pageSize?: number;
@ApiPropertyOptional({

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,12 @@ export interface NormalizedPage {
}
const DEFAULT_PAGE_SIZE = 20;
const MAX_PAGE_SIZE = 100;
/**
* Must stay in step with `@Max` on `PaginationQueryDto.pageSize` and with
* `MAX_PAGE_SIZE` in the backoffice's data-table footer — the DTO rejects,
* this clamps, and the footer is what actually asks for the number.
*/
const MAX_PAGE_SIZE = 500;
/** Clamp raw query values into a safe page window (page ≥ 1, pageSize capped). */
export function normalizePagination(

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -67,6 +67,7 @@ const TRIGGER_ROUTE_LABELS: Partial<Record<Rate['trigger'], string>> = {
DEMURRAGE: 'Demurrage / wagon detention',
PIL_EXTRA_FEE: 'PIL shipping line extra fee',
CUSTOMS_CLEARANCE: 'Customs clearance service',
ETHIOPIAN_CUSTOMS_CLEARANCE: 'Ethiopian customs clearance service',
FUEL: 'Fuel surcharge',
};

View File

@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Columns for `POST /v1/bulkRegister` — see `EimsBulkRegistrationService`.
*
* `eims_system_state.in_flight_conversation_id` is the bulk equivalent of `in_flight_invoice_id`:
* a whole batch, not one invoice, is what's outstanding while MoR processes it asynchronously.
* `invoices.eims_bulk_conversation_id` tags which batch an invoice was submitted in, so a stuck
* batch (webhook never arrived) can be found and reconciled by conversation id.
*/
export class EimsBulkRegistration3580000000000 implements MigrationInterface {
name = "EimsBulkRegistration3580000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.eims_system_state
ADD COLUMN IF NOT EXISTS in_flight_conversation_id text
`);
await queryRunner.query(`
ALTER TABLE freight.invoices
ADD COLUMN IF NOT EXISTS eims_bulk_conversation_id text
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.eims_system_state
DROP COLUMN IF EXISTS in_flight_conversation_id
`);
await queryRunner.query(`
ALTER TABLE freight.invoices
DROP COLUMN IF EXISTS eims_bulk_conversation_id
`);
}
}

View File

@@ -0,0 +1,114 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Reference data for the operations reporting suite (turnaround, delay,
* trainset, TEU, cargo volume).
*
* Two new tables and two new columns:
*
* - `operations_standards` — single-row settings table, same shape as
* `logo_settings` / `exchange_settings`. Holds the railway's standard times
* and charged-tonnage factors. Editable in the backoffice because the
* business calls the corridor standard "flexible".
* - `operations_targets` — the planned side of every "Plan / Operated /
* Implement Rate" table in the spec. One row per period × metric ×
* dimension value.
* - `yard_distances.standard_hours` — the per-corridor standard transit time
* (Negad→GMP 21h, →Adama 20h, →Modjo 20.5h, →Sebeta 22h). Null falls back to
* `operations_standards.default_leg_standard_hours`.
* - `cargo_types.full_trainset_wagons` — wagons in a full trainset of this
* cargo (37 for vehicles, 22 for sand). Null falls back to
* `operations_standards.default_full_trainset_wagons`.
*
* The seed row is inserted only when the table is empty, so re-running this
* never overwrites values an operator has since edited.
*/
export class OperationsReporting3580000000000 implements MigrationInterface {
name = "OperationsReporting3580000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.operations_standards (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
station_standard_hours_ethiopia numeric(6,2) NOT NULL DEFAULT 10,
station_standard_hours_djibouti numeric(6,2) NOT NULL DEFAULT 13,
cycle_standard_hours_container numeric(6,2) NOT NULL DEFAULT 65,
cycle_standard_hours_bulk_dmp numeric(6,2) NOT NULL DEFAULT 88,
cycle_standard_hours_bulk_nagad numeric(6,2) NOT NULL DEFAULT 96,
cycle_standard_hours_bulk_bcc numeric(6,2) NOT NULL DEFAULT 96,
default_leg_standard_hours numeric(6,2) NOT NULL DEFAULT 21,
delay_tolerance_minutes integer NOT NULL DEFAULT 30,
charged_tons_full_20ft numeric(8,2) NOT NULL DEFAULT 20,
charged_tons_full_40ft numeric(8,2) NOT NULL DEFAULT 40,
charged_tons_empty_20ft numeric(8,2) NOT NULL DEFAULT 2.24,
charged_tons_empty_40ft numeric(8,2) NOT NULL DEFAULT 3.88,
charged_tons_per_wagon_general numeric(8,2) NOT NULL DEFAULT 70,
charged_tons_per_wagon_perishable numeric(8,2) NOT NULL DEFAULT 38,
default_full_trainset_wagons integer NOT NULL DEFAULT 50,
updated_by_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
`);
// Column defaults carry every value — the seed only needs the row to exist.
await queryRunner.query(`
INSERT INTO freight.operations_standards (id)
SELECT gen_random_uuid()
WHERE NOT EXISTS (SELECT 1 FROM freight.operations_standards WHERE deleted_at IS NULL);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.operations_targets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
period_type varchar(10) NOT NULL,
period_start date NOT NULL,
metric varchar(20) NOT NULL,
dimension varchar(20) NOT NULL,
dimension_key varchar(60) NOT NULL,
planned_value numeric(14,3) NOT NULL,
note text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
`);
// Partial unique index rather than a table constraint, so a soft-deleted
// target can be re-created — same choice as yard_distances.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS ux_operations_targets_slot
ON freight.operations_targets (period_type, period_start, metric, dimension, dimension_key)
WHERE deleted_at IS NULL;
`);
// The reports look targets up by period and metric, never by id.
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_operations_targets_lookup
ON freight.operations_targets (metric, period_type, period_start)
WHERE deleted_at IS NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.yard_distances
ADD COLUMN IF NOT EXISTS standard_hours numeric(6,2);
`);
await queryRunner.query(`
ALTER TABLE freight.cargo_types
ADD COLUMN IF NOT EXISTS full_trainset_wagons integer;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS full_trainset_wagons;`,
);
await queryRunner.query(
`ALTER TABLE freight.yard_distances DROP COLUMN IF EXISTS standard_hours;`,
);
await queryRunner.query(`DROP TABLE IF EXISTS freight.operations_targets;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.operations_standards;`);
}
}

View File

@@ -0,0 +1,51 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* A station's plan is per station AND per cargo type, not per station.
*
* The OCC monthly report plans "NagadMojo multimodal container 122,010 t" and
* "NagadMojo fertilizer 18,000 t" as separate lines against the same station,
* which the single `dimension_key` column cannot express: a station-keyed target
* would apply the whole station's plan to each of its cargo types.
*
* `cargo_category` is nullable, so `cargo_category` and `container_class`
* targets are unaffected — they leave it null and stay keyed on
* `dimension_key` alone. The uniqueness index moves to include it, since
* (station, category) is now the slot.
*/
export class OperationsTargetCargoCategory3590000000000 implements MigrationInterface {
name = "OperationsTargetCargoCategory3590000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.operations_targets
ADD COLUMN IF NOT EXISTS cargo_category varchar(60);
`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_operations_targets_slot;`);
// COALESCE rather than a plain column list: a partial unique index treats
// NULLs as distinct, which would let the same category target be entered
// twice over.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS ux_operations_targets_slot
ON freight.operations_targets (
period_type, period_start, metric, dimension, dimension_key,
COALESCE(cargo_category, '')
)
WHERE deleted_at IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_operations_targets_slot;`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS ux_operations_targets_slot
ON freight.operations_targets (period_type, period_start, metric, dimension, dimension_key)
WHERE deleted_at IS NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.operations_targets DROP COLUMN IF EXISTS cargo_category;
`);
}
}

View File

@@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/** Per-booking clearance action history — drives the History tab. */
export class BookingClearanceEvent3600000000000 implements MigrationInterface {
name = 'BookingClearanceEvent3600000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "freight"."booking_clearance_event" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
"deleted_at" timestamptz,
"booking_id" uuid NOT NULL,
"action" character varying(64) NOT NULL,
"label" character varying(500) NOT NULL,
"actor_type" character varying(16) NOT NULL DEFAULT 'STAFF',
"actor_id" uuid,
"actor_name" character varying(150),
"metadata" jsonb,
CONSTRAINT "pk_booking_clearance_event" PRIMARY KEY ("id"),
CONSTRAINT "fk_booking_clearance_event_booking" FOREIGN KEY ("booking_id")
REFERENCES "freight"."bookings"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_booking_clearance_event_booking_created"
ON "freight"."booking_clearance_event" ("booking_id", "created_at")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS "freight"."booking_clearance_event"`,
);
}
}

View File

@@ -0,0 +1,38 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Clearance charges are no longer one-of-each in a fixed order: GL Ethiopia
* may raise several MISCELLANEOUS charges, and either level may be created
* first. Port charges stay unique per booking (one port bill per shipment),
* enforced by a partial index instead of the old blanket (booking_id, type)
* uniqueness that also capped miscellaneous at one.
*/
export class MultipleMiscClearanceCharges3610000000000
implements MigrationInterface
{
name = 'MultipleMiscClearanceCharges3610000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP INDEX IF EXISTS "freight"."uq_booking_clearance_charge_booking_type"
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_booking_clearance_charge_port"
ON "freight"."booking_clearance_charge" ("booking_id")
WHERE "type" = 'PORT_CHARGES' AND "deleted_at" IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_booking_clearance_charge_booking"
ON "freight"."booking_clearance_charge" ("booking_id")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// No-op on the uniqueness: restoring the blanket (booking_id, type) index
// would fail on any booking that has since raised a second miscellaneous
// charge, which is exactly what this migration set out to allow.
await queryRunner.query(`
DROP INDEX IF EXISTS "freight"."uq_booking_clearance_charge_port"
`);
}
}

View File

@@ -0,0 +1,43 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/** Ad-hoc customer charges finance raises against a booking — Additional Payments tab. */
export class AdditionalCharge3620000000000 implements MigrationInterface {
name = 'AdditionalCharge3620000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "freight"."additional_charge" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
"deleted_at" timestamptz,
"booking_id" uuid NOT NULL,
"reason" text NOT NULL,
"status" character varying(20) NOT NULL DEFAULT 'DRAFT',
"amount" numeric(14,2) NOT NULL,
"currency" character varying(8) NOT NULL,
"file_record_id" uuid,
"invoice_id" uuid,
"payment_reference" character varying(64),
"created_by_staff_id" uuid,
"sent_by_staff_id" uuid,
"sent_at" timestamptz,
"paid_at" timestamptz,
"cancelled_by_staff_id" uuid,
"cancelled_at" timestamptz,
"cancel_reason" text,
CONSTRAINT "pk_additional_charge" PRIMARY KEY ("id"),
CONSTRAINT "fk_additional_charge_booking" FOREIGN KEY ("booking_id")
REFERENCES "freight"."bookings"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_additional_charge_booking"
ON "freight"."additional_charge" ("booking_id")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "freight"."additional_charge"`);
}
}

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-schedule wagon yard plan — where THIS departure expects each consist
* wagon to board, independent of where the wagon physically stands today.
*
* `wagons.current_yard_id` is one physical fact shared by every schedule of a
* built train, so a train standing in Mojo could not be sold from Dire for a
* departure next week. The plan is a sparse jsonb map `{ wagonId: yardId }`
* on the schedule: a wagon missing from the map boards from its physical yard.
* Booking capacity, fleet availability and wagon pinning all read the plan;
* dispatch refuses to leave until the plan and the physical yards agree.
*/
export class SchedulePlannedWagonYards3620000000000 implements MigrationInterface {
name = 'SchedulePlannedWagonYards3620000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS planned_wagon_yards jsonb
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS planned_wagon_yards
`);
}
}

View File

@@ -0,0 +1,49 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* The customer now approves a clearance charge before it becomes an invoice:
* GL describes the price, SENDs it, the customer ACCEPTs (invoice issued, charge
* locked) or REJECTs with a note (GL revises and re-sends). Charges that were
* already sent as invoices under the old flow are carried over as ACCEPTED so
* their invoices stay payable.
*/
export class ClearanceChargeCustomerDecision3630000000000
implements MigrationInterface
{
name = 'ClearanceChargeCustomerDecision3630000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "freight"."booking_clearance_charge"
ADD COLUMN IF NOT EXISTS "description" text,
ADD COLUMN IF NOT EXISTS "customer_note" text,
ADD COLUMN IF NOT EXISTS "customer_decided_at" timestamptz,
ADD COLUMN IF NOT EXISTS "customer_decided_by" uuid
`);
await queryRunner.query(`
UPDATE "freight"."booking_clearance_charge"
SET "status" = 'ACCEPTED'
WHERE "status" = 'SENT' AND "invoice_id" IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE "freight"."booking_clearance_charge"
SET "status" = 'SENT'
WHERE "status" = 'ACCEPTED'
`);
await queryRunner.query(`
UPDATE "freight"."booking_clearance_charge"
SET "status" = 'BILLED'
WHERE "status" = 'REJECTED'
`);
await queryRunner.query(`
ALTER TABLE "freight"."booking_clearance_charge"
DROP COLUMN IF EXISTS "description",
DROP COLUMN IF EXISTS "customer_note",
DROP COLUMN IF EXISTS "customer_decided_at",
DROP COLUMN IF EXISTS "customer_decided_by"
`);
}
}

View File

@@ -0,0 +1,69 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Ethiopian-side-only customs clearance:
*
* - service_types.includes_ethiopian_customs_only marks a customs service that
* EDR clears on the Ethiopian side only. Same clearance flow; only the fee
* differs — pricing looks up the ETHIOPIAN_CUSTOMS_CLEARANCE rate instead of
* CUSTOMS_CLEARANCE.
* - rates.trigger widens to 30 chars to fit the new trigger value.
* - CK_rates_yard_scope gains ETHIOPIAN_CUSTOMS_CLEARANCE in its yard-carrying
* branch: it is priced per origin → destination leg like customs clearance.
*/
export class EthiopianCustomsClearance3640000000000 implements MigrationInterface {
name = 'EthiopianCustomsClearance3640000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.service_types
ADD COLUMN IF NOT EXISTS includes_ethiopian_customs_only boolean NOT NULL DEFAULT false
`);
await queryRunner.query(
`ALTER TABLE freight.rates ALTER COLUMN trigger TYPE varchar(30)`,
);
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
);
await queryRunner.query(`
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
CASE
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
);
await queryRunner.query(`
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
CASE
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
OR trigger IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
)
`);
// Rows on the new trigger would not fit varchar(20) — drop them first.
await queryRunner.query(
`DELETE FROM freight.rates WHERE trigger = 'ETHIOPIAN_CUSTOMS_CLEARANCE'`,
);
await queryRunner.query(
`ALTER TABLE freight.rates ALTER COLUMN trigger TYPE varchar(20)`,
);
await queryRunner.query(
`ALTER TABLE freight.service_types DROP COLUMN IF EXISTS includes_ethiopian_customs_only`,
);
}
}

View File

@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/** Optional payment due date finance can set on an additional charge. */
export class AdditionalChargeDueAt3650000000000 implements MigrationInterface {
name = 'AdditionalChargeDueAt3650000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "freight"."additional_charge"
ADD COLUMN IF NOT EXISTS "due_at" timestamptz
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "freight"."additional_charge" DROP COLUMN IF EXISTS "due_at"
`);
}
}

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-schedule wagon CUT plan — the mid-route stop where THIS departure
* detaches each consist wagon and leaves it behind (10 wagons cut at Mojo,
* the rest ride to Djibouti).
*
* Sparse jsonb map `{ wagonId: yardId }` on the schedule: a wagon missing
* from the map rides to the schedule destination — exactly today's behavior,
* so no backfill. The cut is a cap, not a promise: cargo may still alight
* earlier, but never past the cut. Booking capacity debits every edge at or
* after the cut; checkpoint logging settles the wagon there physically.
*/
export class SchedulePlannedWagonCutYards3650000000000 implements MigrationInterface {
name = 'SchedulePlannedWagonCutYards3650000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS planned_wagon_cut_yards jsonb
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS planned_wagon_cut_yards
`);
}
}

View File

@@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-schedule consist-change plan, executed automatically as the trip
* proceeds (dispatch / checkpoint logs):
*
* - `planned_wagon_couples` `{ wagonId: pickupYardId }` — LOOSE wagons this
* departure couples onto the train at a route stop. They join the built
* train permanently when the train reaches that stop.
* - `planned_wagon_real_cuts` `[wagonId, ...]` — cut wagons (see
* planned_wagon_cut_yards) flagged as REAL cuts: the built train
* permanently loses the wagon at its cut yard, instead of the default
* soft cut where it stays in the build and only sits out this trip.
*/
export class SchedulePlannedWagonCouples3660000000000 implements MigrationInterface {
name = 'SchedulePlannedWagonCouples3660000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS planned_wagon_couples jsonb
`);
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS planned_wagon_real_cuts jsonb
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS planned_wagon_couples
`);
await queryRunner.query(`
ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS planned_wagon_real_cuts
`);
}
}

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* A consist adjustment made from the TRAIN BUILDER on a train with no live
* schedule still belongs in the wagon adjustment history — it just has no
* schedule to point at. Relax the NOT NULL so builder detaches/attaches can
* be recorded; every existing reader filters BY train_schedule_id or
* train_id, so nullable rows are invisible to them.
*/
export class AdjustmentLogNullableSchedule3670000000000 implements MigrationInterface {
name = 'AdjustmentLogNullableSchedule3670000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.schedule_wagon_adjustment_logs
ALTER COLUMN train_schedule_id DROP NOT NULL
`);
}
public async down(_queryRunner: QueryRunner): Promise<void> {
// No-op: restoring NOT NULL would fail on any builder-origin rows written
// while this migration was live, re-introducing the outage it fixed.
}
}

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Every slot → allocations lookup (allocator, journey load/unload, settle,
* per-leg weight guard) filters wagon_booking_allocations by
* train_set_wagon_id, which had no index — only booking_id and the pkey.
* Sequential scans grow with every allocation ever written.
*/
export class WagonAllocationSlotIndex3680000000000 implements MigrationInterface {
name = 'WagonAllocationSlotIndex3680000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wagon_booking_allocations_slot
ON freight.wagon_booking_allocations (train_set_wagon_id)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP INDEX IF EXISTS freight.idx_wagon_booking_allocations_slot
`);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,36 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Attaches an eTrade business licence to each operational profile.
*
* A TIN routinely holds a dozen or more licences, split by activity ("Export
* trade in coffee", "Freight Forwarders"), and until now the company picked one
* for the whole record — every role shared it. Each profile now names the
* business it actually operates as.
*
* Stored as a snapshot ({@link ETradeBusinessOption}: licenceNumber, tradeName,
* activity, renewedTo) rather than a bare licence number, so the portal and the
* backoffice can show which business is attached without an eTrade round-trip —
* eTrade is slow, serves a broken TLS chain, and is regularly down.
*
* Nullable: existing profiles have none until the customer attaches one, and a
* co-operative or investor-licence company has no eTrade record at all.
* Deliberately NOT unique — one business can back several profiles.
*/
export class CompanyProfileEtradeBusiness3760000000000 implements MigrationInterface {
name = 'CompanyProfileEtradeBusiness3760000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.company_profiles
ADD COLUMN IF NOT EXISTS etrade_business jsonb
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.company_profiles
DROP COLUMN IF EXISTS etrade_business
`);
}
}

View File

@@ -0,0 +1,65 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-wagon loading. Staff may confirm loading wagon-by-wagon instead of the
* whole booking at once:
* - bookings.loading_started_at — first wagon loaded; the booking stays PAID
* until every remaining wagon is LOADED (remaining = allocated cancelled).
* Also shields a mid-load booking from the dispatch "left behind" unassign.
* - wagon_booking_allocations.loaded_at / loaded_by_user_id — per-wagon
* confirmation audit.
* - booking_wagon_cancellations.fault — who caused an at-loading cancel of
* the never-loaded remainder: CUSTOMER (fee applies) or EDR (no fee, credit
* rebookable in full).
*/
export class PerWagonLoading3760000000000 implements MigrationInterface {
name = 'PerWagonLoading3760000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS loading_started_at timestamptz`,
);
await queryRunner.query(
`ALTER TABLE freight.wagon_booking_allocations
ADD COLUMN IF NOT EXISTS loaded_at timestamptz`,
);
await queryRunner.query(
`ALTER TABLE freight.wagon_booking_allocations
ADD COLUMN IF NOT EXISTS loaded_by_user_id uuid`,
);
await queryRunner.query(
`ALTER TABLE freight.wagon_booking_allocations
ADD COLUMN IF NOT EXISTS unloaded_at timestamptz`,
);
await queryRunner.query(
`ALTER TABLE freight.wagon_booking_allocations
ADD COLUMN IF NOT EXISTS unloaded_by_user_id uuid`,
);
await queryRunner.query(
`ALTER TABLE freight.booking_wagon_cancellations
ADD COLUMN IF NOT EXISTS fault varchar(16)`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.booking_wagon_cancellations DROP COLUMN IF EXISTS fault`,
);
await queryRunner.query(
`ALTER TABLE freight.wagon_booking_allocations DROP COLUMN IF EXISTS loaded_by_user_id`,
);
await queryRunner.query(
`ALTER TABLE freight.wagon_booking_allocations DROP COLUMN IF EXISTS unloaded_by_user_id`,
);
await queryRunner.query(
`ALTER TABLE freight.wagon_booking_allocations DROP COLUMN IF EXISTS unloaded_at`,
);
await queryRunner.query(
`ALTER TABLE freight.wagon_booking_allocations DROP COLUMN IF EXISTS loaded_at`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS loading_started_at`,
);
}
}

View File

@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Why a wagon left (or joined) the consist, on the adjustment log itself.
*
* SCHEDULED-run detach / send-to-maintenance now requires a reason instead of
* a second staffer's approval, so the reason has to read back where the change
* reads back: the train-builder History tab. Nullable — every other writer
* (trip cuts, couples, arrival returns) keeps logging without one.
*/
export class WagonAdjustmentReason3770000000000 implements MigrationInterface {
name = 'WagonAdjustmentReason3770000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.schedule_wagon_adjustment_logs
ADD COLUMN IF NOT EXISTS reason varchar(500)`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.schedule_wagon_adjustment_logs
DROP COLUMN IF EXISTS reason`,
);
}
}

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Why a train schedule was cancelled, captured at cancel time. Staff pick a
* reason in the cancel dialog and every view of the cancelled schedule reads it
* back — a cancelled train on the board used to say nothing about why it died.
*/
export class ScheduleCancellationReason3780000000000 implements MigrationInterface {
name = 'ScheduleCancellationReason3780000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "freight"."train_schedules"
ADD COLUMN IF NOT EXISTS "cancellation_reason" varchar(500),
ADD COLUMN IF NOT EXISTS "cancelled_at" timestamptz,
ADD COLUMN IF NOT EXISTS "cancelled_by_user_id" uuid
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "freight"."train_schedules"
DROP COLUMN IF EXISTS "cancellation_reason",
DROP COLUMN IF EXISTS "cancelled_at",
DROP COLUMN IF EXISTS "cancelled_by_user_id"
`);
}
}

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Empties backfilled into the yard belong to a company that may not be a
* registered customer yet, so `customer_id` cannot hold it. `company_name` is
* the typed fallback, and the display label when the customer IS registered.
*/
export class EmptyContainerReturnCompanyName3790000000000 implements MigrationInterface {
name = 'EmptyContainerReturnCompanyName3790000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.empty_container_returns
ADD COLUMN IF NOT EXISTS company_name varchar(200)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.empty_container_returns
DROP COLUMN IF EXISTS company_name
`);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,112 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { In, Repository } from "typeorm";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { ExternalProfile } from "../companies/entities/external-profile.entity";
/**
* One portal login belonging to a customer company: the company-side profile
* joined to the IAM account that actually signs in.
*
* The two halves drift apart routinely — `company.email` is business contact
* detail, while `email` here is the credential a reset link goes to — which is
* exactly why staff need to see the IAM side rather than the company row.
*/
export interface CustomerAccount {
/** external_profiles.id */
profileId: string;
userId: string;
firstName: string;
lastName: string;
jobTitle: string | null;
isPrimaryContact: boolean;
onboardingStep: string | null;
onboardingCompleted: boolean;
/** Null when the profile points at a user row that no longer exists. */
username: string | null;
email: string | null;
phoneNumber: string | null;
phoneVerified: boolean | null;
/** IAM account status (`EUserStatus`), surfaced as-is. */
status: string | null;
isActive: boolean | null;
/** False means the account was created but never activated by its owner. */
hasSetPassword: boolean | null;
createdAt: Date;
}
@Injectable()
export class CustomerAccountsService {
constructor(
@InjectRepository(ExternalProfile)
private readonly profiles: Repository<ExternalProfile>,
@InjectRepository(User)
private readonly users: Repository<User>,
) {}
/**
* Every portal account for a company, primary contact first.
*
* Deliberately NOT filtered to active accounts: a suspended or never-activated
* login is the case staff are usually looking into, and hiding it would leave
* "the customer says they can't log in" unanswerable from this screen.
*/
async listForCompany(companyId: string): Promise<CustomerAccount[]> {
const profiles = await this.profiles.find({ where: { companyId } });
if (profiles.length === 0) return [];
const userIds = profiles.map((p) => p.userId).filter(Boolean);
// Explicit select: the User entity's relations include credentials and
// sessions, and this response goes to a browser.
const users = userIds.length
? await this.users
.createQueryBuilder("user")
.select([
"user.id",
"user.username",
"user.email",
"user.phoneNumber",
"user.isPhoneNumberVerified",
"user.status",
"user.isActive",
"user.hasSetPassword",
])
.where({ id: In(userIds) })
.getMany()
: [];
const byId = new Map(users.map((u) => [u.id, u]));
return profiles
.map((p) => {
const user = byId.get(p.userId);
return {
profileId: p.id,
userId: p.userId,
firstName: p.firstName,
lastName: p.lastName,
jobTitle: p.jobTitle ?? null,
isPrimaryContact: p.isPrimaryContact,
onboardingStep: p.onboardingStep ?? null,
onboardingCompleted: p.onboardingCompleted ?? false,
username: user?.username ?? null,
email: user?.email ?? null,
phoneNumber: user?.phoneNumber ?? null,
phoneVerified: user?.isPhoneNumberVerified ?? null,
status: user?.status ?? null,
isActive: user?.isActive ?? null,
hasSetPassword: user?.hasSetPassword ?? null,
createdAt: p.createdAt,
};
})
.sort((a, b) => {
// Primary contact first — it is the account every staff action
// (password reset, notifications) actually targets.
if (a.isPrimaryContact !== b.isPrimaryContact) {
return a.isPrimaryContact ? -1 : 1;
}
return a.createdAt.getTime() - b.createdAt.getTime();
});
}
}

View File

@@ -12,6 +12,10 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { BackofficeResetPasswordDto } from "./dto/forgot-password.dto";
import {
CustomerAccount,
CustomerAccountsService,
} from "./customer-accounts.service";
import {
CustomerResetService,
CustomerResetTarget,
@@ -25,7 +29,22 @@ import {
@Controller("backoffice/customers")
@ApiBearerAuth()
export class CustomerResetController {
constructor(private readonly customerResetService: CustomerResetService) {}
constructor(
private readonly customerResetService: CustomerResetService,
private readonly customerAccountsService: CustomerAccountsService,
) {}
@Get(":companyId/accounts")
@BookingStaff(FREIGHT_PERMS.customers.view)
@ApiOperation({
summary:
"The portal login accounts belonging to a customer, primary contact first",
})
async accounts(
@Param("companyId", ParseUUIDPipe) companyId: string,
): Promise<CustomerAccount[]> {
return this.customerAccountsService.listForCompany(companyId);
}
@Get(":companyId/reset-target")
@BookingStaff(FREIGHT_PERMS.customers.resetPassword)

View File

@@ -13,6 +13,7 @@ import { AccountController } from './account.controller';
import { AccountService } from './account.service';
import { CheckAvailabilityController } from './check-availability.controller';
import { CheckAvailabilityService } from './check-availability.service';
import { CustomerAccountsService } from './customer-accounts.service';
import { CustomerResetController } from './customer-reset.controller';
import { CustomerResetService } from './customer-reset.service';
import { ForgotPasswordController } from './forgot-password.controller';
@@ -50,6 +51,7 @@ import { ListUsersService } from './list-users.service';
CheckAvailabilityService,
ForgotPasswordService,
CustomerResetService,
CustomerAccountsService,
],
// Shipping-line registration mints activation links through the same
// staff-triggered reset path customers use.

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { Freight } from "@edr/types";
import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "../bookings/entities/booking-wagon-cancellation.entity";
import { BillingService } from "./billing.service";
/**
@@ -938,8 +939,12 @@ describe("BillingService.document", () => {
const build = (invoice: Record<string, unknown>) => {
const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") });
const renderThermal = jest.fn().mockResolvedValue({ filename: "x-thermal.pdf", buffer: Buffer.from("") });
// `toDocumentModel` reads the booking (route/wagons, PNR) straight off the
// data source for a booking-sourced invoice — a stub that answers "no such
// booking" keeps these summary assertions about the invoice itself.
const dataSource = { getRepository: () => ({ findOne: jest.fn().mockResolvedValue(null) }) };
const service = new BillingService(
{} as never,
dataSource as never,
{ findById: jest.fn().mockResolvedValue(invoice) } as never,
{ findAll: jest.fn().mockResolvedValue([]) } as never,
{} as never,
@@ -1013,6 +1018,34 @@ describe("BillingService.document", () => {
expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload");
});
it("prints the provider transaction reference of a settled invoice", async () => {
const { service, render } = build(
invoiceRow({
status: Freight.InvoiceStatus.Paid,
paidAmount: 100,
balanceAmount: 0,
payments: [{ amount: 100, method: "GATEWAY", reference: "FT26082700123", paidAt: "2026-08-27T09:00:00.000Z" }],
payment: { transactionId: "FT26082700123" },
}),
);
await service.document("inv-1");
const model = render.mock.calls[0][0];
expect(model.summary).toContainEqual({ label: "Transaction ref", value: "FT26082700123" });
});
it("adds no transaction reference row to an unpaid invoice", async () => {
const { service, render } = build(invoiceRow());
await service.document("inv-1");
const model = render.mock.calls[0][0];
expect(
model.summary.find((r: { label: string }) => r.label === "Transaction ref"),
).toBeUndefined();
});
it("calls render (not renderThermal) for the default format", async () => {
const { service, render, renderThermal } = build(invoiceRow());
jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never);
@@ -1033,3 +1066,73 @@ describe("BillingService.document", () => {
expect(render).not.toHaveBeenCalled();
});
});
/**
* The pay-window guard belongs to the freight invoice. A wagon-cancellation fee
* rides source=booking but is raised on an already-PAID booking, so it inherits
* a deadline that has long passed — guarding it would make the fee permanently
* unsettleable.
*/
describe("BillingService.confirmOfflinePayment pay-window guard", () => {
const PAST = new Date(Date.now() - 86_400_000);
function makeService(invoiceType: string) {
const invoice = {
id: "inv-1",
source: Freight.InvoiceSource.Booking,
sourceId: "booking-1",
type: invoiceType,
currency: "ETB",
status: Freight.InvoiceStatus.Issued,
balanceAmount: 500,
};
const recordPayment = jest.fn().mockResolvedValue(invoice);
const dataSource = {
getRepository: () => ({
findOne: async () => ({ id: "booking-1", paymentDeadline: PAST }),
}),
};
const service = new BillingService(
dataSource as never,
{ findById: async () => invoice } as never,
{} as never,
makeEvents() as never,
{} as never,
{} as never,
{} as never,
{ upload: async () => ({ id: "file-1", name: "slip.pdf" }) } as never,
{ get: () => undefined } as never,
{ isEnabled: async () => true } as never,
);
(service as unknown as { recordPayment: unknown }).recordPayment =
recordPayment;
return { service, recordPayment };
}
const slip = { originalname: "slip.pdf" } as never;
it("refuses a freight invoice once the pay window has closed", async () => {
const { service } = makeService("PREPAID");
await expect(
service.confirmOfflinePayment("inv-1", slip, {}),
).rejects.toThrow(/payment window has closed/i);
});
it("settles a wagon-cancellation fee despite the closed window", async () => {
const { service, recordPayment } = makeService(
WAGON_CANCEL_FEE_INVOICE_TYPE,
);
await service.confirmOfflinePayment("inv-1", slip, {});
expect(recordPayment).toHaveBeenCalledWith(
"inv-1",
expect.objectContaining({ amount: 500, method: "BANK_TRANSFER" }),
);
});
it("still requires the bank slip for a cancellation fee", async () => {
const { service } = makeService(WAGON_CANCEL_FEE_INVOICE_TYPE);
await expect(
service.confirmOfflinePayment("inv-1", undefined, {}),
).rejects.toThrow(/slip file is required/i);
});
});

View File

@@ -13,6 +13,8 @@ import { logCtx } from "@edr/api-common";
import { DataSource, EntityManager, In, SelectQueryBuilder } from "typeorm";
import { Booking } from "../bookings/entities/booking.entity";
import { AdditionalCharge } from "../bookings/entities/additional-charge.entity";
import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "../bookings/entities/booking-wagon-cancellation.entity";
// Entity-only import (no module edge): portal reads resolve shipping-line
// payers straight off the table.
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
@@ -27,14 +29,21 @@ import { PaymentService } from "../payment/payment.service";
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
import {
InvoiceDocumentModel,
sameCompanyName,
InvoiceDocumentService,
pngDataUrl,
} from "./documents/invoice-document.service";
import { INVOICE_SORT_COLUMNS } from "./dto/filter-invoice.dto";
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,
settlementReferences,
} from "./invoice-settlement.util";
import { InvoiceRepository } from "./invoice.repository";
/** Options forwarded to the payment gateway when settling an invoice. */
@@ -46,6 +55,29 @@ export interface PayInvoiceOptions {
failureUrl?: string;
}
/**
* What an invoice's `sourceId` actually points at, resolved for display.
*
* `source` alone ("warehouse", "booking", …) says which subsystem raised the
* invoice but nothing about *which* record, and `sourceId` is a raw UUID. Every
* source except a shipping-line credit hangs off a booking — directly
* (booking/clearance) or through the warehouse/first-mile/last-mile record —
* so the booking reference is the one label that identifies almost any row.
*/
export interface InvoiceSourceRef {
/** Booking behind the invoice, when there is one. Null for shipping-line credits. */
bookingId: string | null;
bookingReference: string | null;
tradeDirection: string | null;
/** Warehouse-sourced rows: the goods-received note the fees were raised against. */
grnNumber: string | null;
/** Shipping-line credit rows: `sourceId` is the line's own id, not a record's. */
shippingLineName: string | null;
}
/** Row shape of the backoffice invoice list: the entity plus its resolved source. */
export type InvoiceListRow = Invoice & { sourceRef: InvoiceSourceRef | null };
/** Booking context attached to a finance offline-USD invoice row. */
export interface OfflineUsdBookingInfo {
id: string;
@@ -74,6 +106,42 @@ export interface RecordPaymentInput {
}
/** Default invoice payment-term window, in days, used to compute `dueAt`. */
/**
* Every dimension the backoffice invoice list narrows by. `findAllPaginated`
* and `collectedSummary` share it so the summary card can never total a
* different set of invoices than the table below it shows.
*/
export interface InvoiceListFilters {
companyId?: string;
status?: Freight.InvoiceStatus;
statuses?: Freight.InvoiceStatus[];
sources?: string[];
/** What the invoice bills for (`PREPAID`, `DEMURRAGE`, …) — free-form per source. */
types?: string[];
eimsStatuses?: string[];
/** Settled payment method, normalised UPPER_SNAKE — see `invoicePaymentMethodExpr`. */
paymentMethods?: string[];
currency?: string;
search?: string;
issuedFrom?: string;
issuedTo?: string;
dueFrom?: string;
dueTo?: string;
minAmount?: number;
maxAmount?: number;
hasBalance?: boolean;
overdue?: boolean;
/** Per-user trade-direction scope, applied via the source booking. */
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. */
@@ -203,7 +271,7 @@ export class BillingService {
private readonly files: FilesService,
private readonly config: ConfigService,
private readonly manualPaymentSettings: ManualPaymentSettingsService,
) { }
) {}
// ── Reads ──────────────────────────────────────────────────────────────────
@@ -220,12 +288,7 @@ export class BillingService {
/** Same list filters `findAllPaginated` and `collectedSummary` both narrow by. */
private applyInvoiceFilters(
qb: SelectQueryBuilder<Invoice>,
filter: {
companyId?: string;
status?: Freight.InvoiceStatus;
search?: string;
tradeDirections?: string[];
},
filter: InvoiceListFilters,
) {
if (filter.companyId) {
qb.andWhere("invoice.companyId = :companyId", {
@@ -235,9 +298,102 @@ export class BillingService {
if (filter.status) {
qb.andWhere("invoice.status = :status", { status: filter.status });
}
if (filter.statuses?.length) {
qb.andWhere("invoice.status IN (:...statuses)", {
statuses: filter.statuses,
});
}
if (filter.sources?.length) {
qb.andWhere("invoice.source IN (:...sources)", {
sources: filter.sources,
});
}
if (filter.types?.length) {
qb.andWhere("invoice.type IN (:...types)", { types: filter.types });
}
if (filter.eimsStatuses?.length) {
qb.andWhere("invoice.eimsStatus IN (:...eimsStatuses)", {
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", {
currency: filter.currency.toUpperCase(),
});
}
if (filter.issuedFrom) {
qb.andWhere("invoice.issuedAt >= :issuedFrom", {
issuedFrom: filter.issuedFrom,
});
}
if (filter.issuedTo) {
qb.andWhere("invoice.issuedAt <= :issuedTo", {
issuedTo: filter.issuedTo,
});
}
if (filter.dueFrom) {
qb.andWhere("invoice.dueAt >= :dueFrom", { dueFrom: filter.dueFrom });
}
if (filter.dueTo) {
qb.andWhere("invoice.dueAt <= :dueTo", { dueTo: filter.dueTo });
}
if (filter.minAmount !== undefined) {
qb.andWhere("invoice.totalAmount >= :minAmount", {
minAmount: filter.minAmount,
});
}
if (filter.maxAmount !== undefined) {
qb.andWhere("invoice.totalAmount <= :maxAmount", {
maxAmount: filter.maxAmount,
});
}
if (filter.hasBalance) {
qb.andWhere("invoice.balanceAmount > 0");
}
if (filter.overdue) {
// Computed, not `status = OVERDUE`: nothing sweeps PENDING rows into
// that status, so reading the column alone under-reports the arrears.
qb.andWhere("invoice.balanceAmount > 0 AND invoice.dueAt < now()");
}
if (filter.search) {
// 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` and `payment` aliases — every caller joins both.
qb.andWhere(
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
`(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 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
OR lm.id::text = invoice.source_id))
OR EXISTS (
SELECT 1 FROM freight.warehouse_inventory wi2
WHERE wi2.id::text = invoice.source_id
AND wi2.grn_number ILIKE :search)
OR EXISTS (
SELECT 1 FROM freight.shipping_line_companies slc
WHERE slc.id::text = invoice.source_id
AND slc.name ILIKE :search))`,
{ search: `%${filter.search}%` },
);
}
@@ -252,16 +408,13 @@ export class BillingService {
}
async findAllPaginated(
filter: {
companyId?: string;
status?: Freight.InvoiceStatus;
search?: string;
filter: InvoiceListFilters & {
page?: number;
pageSize?: number;
/** Per-user trade-direction scope, applied via the source booking. */
tradeDirections?: string[];
sortBy?: string;
sortOrder?: "ASC" | "DESC";
} = {},
): Promise<{ items: Invoice[]; total: number }> {
): Promise<{ items: InvoiceListRow[]; total: number }> {
const page = filter.page && filter.page > 0 ? filter.page : 1;
const pageSize =
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
@@ -270,14 +423,109 @@ export class BillingService {
.getRepository(Invoice)
.createQueryBuilder("invoice")
.leftJoinAndSelect("invoice.company", "company")
.orderBy("invoice.issuedAt", "DESC")
// 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).
.orderBy(
INVOICE_SORT_COLUMNS[filter.sortBy ?? ""] ?? "invoice.issuedAt",
filter.sortOrder ?? "DESC",
)
.addOrderBy("invoice.id", "ASC")
.skip((page - 1) * pageSize)
.take(pageSize);
this.applyInvoiceFilters(qb, filter);
const [items, total] = await qb.getManyAndCount();
return { items: await this.attachShippingLineCompanies(items), total };
const withLines = await this.attachShippingLineCompanies(items);
return { items: await this.attachSourceRefs(withLines), total };
}
/**
* Resolve each row's `sourceId` to the record it points at, in one query for
* the whole page. `sourceId` is a bare varchar pointer with no FK and no
* relation to eager-load, and which table it addresses depends on `source` —
* so this walks every candidate table at once and lands on the booking
* through whichever one matched.
*
* `sourceId` is not always a UUID (EIMS self-test rows carry a slug), hence
* the shape guard before every cast — an unguarded `::uuid` throws on those.
*/
private async attachSourceRefs<T extends Invoice>(
invoices: T[],
): Promise<(T & { sourceRef: InvoiceSourceRef | null })[]> {
const sourceIds = [
...new Set(invoices.map((i) => i.sourceId).filter(Boolean)),
];
if (!sourceIds.length) {
return invoices.map((invoice) => ({ ...invoice, sourceRef: null }));
}
const rows: {
sourceId: string;
bookingId: string | null;
bookingReference: string | null;
tradeDirection: string | null;
grnNumber: string | null;
shippingLineName: string | null;
}[] = await this.dataSource.query(
`SELECT s.source_id AS "sourceId",
b.id::text AS "bookingId",
b.reference AS "bookingReference",
b.trade_direction AS "tradeDirection",
wi.grn_number AS "grnNumber",
slc.name AS "shippingLineName"
FROM unnest($1::text[]) AS s(source_id)
LEFT JOIN freight.warehouse_inventory wi
ON wi.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$'
THEN s.source_id::uuid END)
AND wi.deleted_at IS NULL
LEFT JOIN freight.first_mile fm
ON fm.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$'
THEN s.source_id::uuid END)
AND fm.deleted_at IS NULL
LEFT JOIN freight.last_mile lm
ON lm.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$'
THEN s.source_id::uuid END)
AND lm.deleted_at IS NULL
LEFT JOIN freight.bookings b
ON b.id = COALESCE(wi.booking_id, fm.booking_id, lm.booking_id,
CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$'
THEN s.source_id::uuid END)
AND b.deleted_at IS NULL
LEFT JOIN freight.shipping_line_companies slc
ON slc.id = (CASE WHEN s.source_id ~ '^[0-9a-fA-F-]{36}$'
THEN s.source_id::uuid END)
AND slc.deleted_at IS NULL`,
[sourceIds],
);
const bySourceId = new Map(rows.map((r) => [r.sourceId, r]));
return invoices.map((invoice) => {
const row = bySourceId.get(invoice.sourceId);
const sourceRef: InvoiceSourceRef | null = row
? {
bookingId: row.bookingId,
bookingReference: row.bookingReference,
tradeDirection: row.tradeDirection,
grnNumber: row.grnNumber,
shippingLineName: row.shippingLineName,
}
: null;
// Nothing resolved (an EIMS self-test row, a deleted record) → null,
// and the UI falls back to the plain source label.
const resolved =
sourceRef &&
(sourceRef.bookingId ||
sourceRef.grnNumber ||
sourceRef.shippingLineName)
? sourceRef
: null;
return { ...invoice, sourceRef: resolved };
});
}
/**
@@ -326,16 +574,15 @@ export class BillingService {
* visible page.
*/
async collectedSummary(
filter: {
companyId?: string;
status?: Freight.InvoiceStatus;
search?: string;
tradeDirections?: string[];
} = {},
filter: InvoiceListFilters = {},
): Promise<Record<string, number>> {
const qb = this.dataSource
.getRepository(Invoice)
.createQueryBuilder("invoice")
// 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");
@@ -353,21 +600,28 @@ export class BillingService {
/**
* Finance's manual-settlement worklist: USD invoices (paid by bank transfer,
* never through the gateway) and ETB invoices Finance settles by hand (bank
* transfer / counter) instead of the customer paying online. Open ones by
* default or a single status when filtered; both currencies unless
* `currency` narrows it. Booking-sourced rows carry the booking's reference,
* trade direction and pay-window deadline so the UI can show the countdown
* and link to the booking.
* transfer / counter) instead of the customer paying online. Both currencies
* unless `currency` narrows it, and only ones whose manual-payment channel is
* switched on. Open ones by default — pin `status` or `statuses` to widen
* that. Every other dimension is the invoice list's own (`applyInvoiceFilters`
* + `INVOICE_SORT_COLUMNS`), so the two screens filter and sort alike.
* Booking-sourced rows carry the booking's reference, trade direction and
* pay-window deadline so the UI can show the countdown and link to the
* booking.
*/
async findOfflineUsdPaginated(
filter: {
status?: Freight.InvoiceStatus;
search?: string;
currency?: "USD" | "ETB";
filter: InvoiceListFilters & {
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: "ASC" | "DESC";
} = {},
): Promise<{ items: OfflineUsdInvoiceRow[]; total: number }> {
): Promise<{
items: OfflineUsdInvoiceRow[];
total: number;
/** Sum of `balanceAmount` over the WHOLE filtered set, by currency. */
outstanding: Record<string, number>;
}> {
const page = filter.page && filter.page > 0 ? filter.page : 1;
const pageSize =
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
@@ -376,33 +630,75 @@ export class BillingService {
// a row Finance cannot act on is noise, and the confirm endpoint would
// refuse it anyway. All off → nothing to work.
const enabled = await this.manualPaymentSettings.enabledCurrencies();
if (!enabled.length) return { items: [], total: 0 };
const currencies = filter.currency
? enabled.filter((c) => c === filter.currency)
: enabled;
if (!currencies.length) return { items: [], total: 0 };
const empty = { items: [], total: 0, outstanding: {} };
if (!enabled.length) return empty;
const wanted = filter.currency?.toUpperCase();
const currencies = wanted ? enabled.filter((c) => c === wanted) : enabled;
if (!currencies.length) return empty;
const qb = this.dataSource
.getRepository(Invoice)
.createQueryBuilder("invoice")
.leftJoinAndSelect("invoice.company", "company")
.where("UPPER(invoice.currency) IN (:...currencies)", { currencies })
.orderBy("invoice.issuedAt", "DESC")
/**
* The worklist narrows by the same vocabulary as the main invoice list, so
* both share `applyInvoiceFilters` — which references the `company` and
* `payment` aliases, hence the unconditional joins. `select` is false for
* the aggregate pass, where joined columns would break the GROUP BY.
*/
const buildQb = (select: boolean) => {
const qb = this.dataSource
.getRepository(Invoice)
.createQueryBuilder("invoice");
if (select) {
qb.leftJoinAndSelect("invoice.company", "company").leftJoinAndSelect(
"invoice.payment",
"payment",
);
} else {
qb.leftJoin("invoice.company", "company").leftJoin(
"invoice.payment",
"payment",
);
}
qb.where("UPPER(invoice.currency) IN (:...currencies)", { currencies });
// "What still needs settling" is the default cut, but only until the
// caller pins a status — either the single-status param or the filter
// bar's multi-select.
if (!filter.status && !filter.statuses?.length) {
qb.andWhere("invoice.status IN (:...open)", { open: OPEN_STATUSES });
}
// `currency` is already enforced by the enabled-currency IN above, and
// re-applying it would only repeat the same predicate.
this.applyInvoiceFilters(qb, { ...filter, currency: undefined });
return qb;
};
const qb = buildQb(true)
// sortBy is whitelisted through INVOICE_SORT_COLUMNS, never interpolated
// raw; the id tiebreaker keeps paging stable when the column ties.
.orderBy(
INVOICE_SORT_COLUMNS[filter.sortBy ?? ""] ?? "invoice.issuedAt",
filter.sortOrder ?? "DESC",
)
.addOrderBy("invoice.id", "ASC")
.skip((page - 1) * pageSize)
.take(pageSize);
if (filter.status) {
qb.andWhere("invoice.status = :status", { status: filter.status });
} else {
qb.andWhere("invoice.status IN (:...open)", { open: OPEN_STATUSES });
}
if (filter.search) {
qb.andWhere(
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
{ search: `%${filter.search}%` },
);
}
const [rawItems, total] = await qb.getManyAndCount();
// Outstanding across the whole filtered set, not the visible page — the
// KPI must not change as Finance pages through the worklist.
const outstandingRows: { currency: string; outstanding: string }[] =
await buildQb(false)
.select("invoice.currency", "currency")
.addSelect("SUM(invoice.balanceAmount)", "outstanding")
.groupBy("invoice.currency")
.getRawMany();
// Folded case-insensitively on the way out: stored casing has drifted
// ("usd" rows exist), so two groups can address the same currency.
const outstanding: Record<string, number> = {};
for (const row of outstandingRows) {
const key = (row.currency ?? "").toUpperCase();
outstanding[key] =
(outstanding[key] ?? 0) + (Number(row.outstanding) || 0);
}
const items = await this.attachShippingLineCompanies(rawItems);
const bookingIds = items
@@ -466,6 +762,7 @@ export class BillingService {
} as OfflineUsdInvoiceRow;
}),
total,
outstanding,
};
}
@@ -503,7 +800,14 @@ export class BillingService {
throw new BadRequestException("The bank payment slip file is required.");
}
if (invoice.source === "booking") {
// The pay window belongs to the freight invoice. A wagon-cancellation fee
// rides source=booking but is raised on an ALREADY-PAID booking, so it
// inherits a deadline that has long passed — guarding it would make the fee
// permanently unsettleable.
if (
invoice.source === "booking" &&
invoice.type !== WAGON_CANCEL_FEE_INVOICE_TYPE
) {
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: invoice.sourceId },
select: ["id", "paymentDeadline"],
@@ -542,7 +846,7 @@ export class BillingService {
/** Invoice header plus its line items. */
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
const invoice = await this.invoices.findById(id, {
relations: { company: true, companyProfile: true },
relations: { company: true, companyProfile: true, payment: true },
});
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
const [hydrated] = await this.attachShippingLineCompanies([invoice]);
@@ -605,7 +909,9 @@ export class BillingService {
{
label: "Wagons",
value:
booking.wagonsRequired != null ? String(booking.wagonsRequired) : null,
booking.wagonsRequired != null
? String(booking.wagonsRequired)
: null,
},
];
}
@@ -632,10 +938,21 @@ export class BillingService {
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
const tradeName = invoice.companyProfile?.etradeBusiness?.tradeName?.trim();
const summary: InvoiceDocumentModel["summary"] = [
// Buyer identity — was missing entirely; a MoR-registered invoice must show who it was
// filed against, not just the seller. VatNumber shown only when the company has one.
{ label: "Buyer", value: invoice.company?.name ?? null },
// The trade name of the eTrade licence THIS profile operates as. A TIN
// holds many licences and the invoiced role (importer/exporter/forwarder)
// is usually a different business from the one the company registered
// under, so the buyer's name alone doesn't say which one was billed.
// Suppressed when it just repeats the buyer name — most companies trade
// under their registered name and a duplicate row helps nobody.
...(tradeName && !sameCompanyName(tradeName, invoice.company?.name)
? [{ label: "Buyer trade name", value: tradeName }]
: []),
{ label: "Buyer TIN", value: invoice.company?.tin ?? null },
...(invoice.company?.vatNumber
? [{ label: "Buyer VAT No.", value: invoice.company.vatNumber }]
@@ -664,11 +981,24 @@ export class BillingService {
const eimsCfg = this.config.get<EimsConfig>("eims");
if (eimsCfg?.tin) summary.push({ label: "Seller TIN", value: eimsCfg.tin });
if (eimsCfg?.invoice?.sellerVatNumber) {
summary.push({ label: "Seller VAT No.", value: eimsCfg.invoice.sellerVatNumber });
summary.push({
label: "Seller VAT No.",
value: eimsCfg.invoice.sellerVatNumber,
});
}
// MoR EIMS reference — only once actually registered, never a placeholder row.
if (invoice.eimsIrn) summary.push({ label: "EIMS IRN", value: invoice.eimsIrn });
if (invoice.eimsIrn)
summary.push({ label: "EIMS IRN", value: invoice.eimsIrn });
// The provider's transaction number for the money actually received — CBE's `FT…`,
// telebirr's receipt number, or the bank-slip reference a teller recorded manually.
// It is what a payer holding a receipt can match this invoice against, and what
// finance reconciles a bank statement with; without it a PAID invoice proves only
// that EDR says it was paid. `findById` already loads the `payment` relation, so both
// sources are in hand here — see settlementReferences for why both are read.
const txnRefs = settlementReferences(invoice);
if (txnRefs) summary.push({ label: "Transaction ref", value: txnRefs });
// PNR — the CBE_BILL reference the customer pays against, written onto the booking at
// payment-initiation time (see initiatePayment()). Not a column on Invoice/Payment, so
@@ -678,7 +1008,8 @@ export class BillingService {
where: { id: invoice.sourceId },
select: ["id", "pnrCode"],
});
if (booking?.pnrCode) summary.push({ label: "PNR", value: booking.pnrCode });
if (booking?.pnrCode)
summary.push({ label: "PNR", value: booking.pnrCode });
}
return {
@@ -699,7 +1030,9 @@ export class BillingService {
currency: l.currency,
})),
totals,
qrImageUrl: invoice.eimsSignedQr ? pngDataUrl(invoice.eimsSignedQr) : null,
qrImageUrl: invoice.eimsSignedQr
? pngDataUrl(invoice.eimsSignedQr)
: null,
};
}
@@ -958,7 +1291,9 @@ export class BillingService {
metadata: l.metadata ?? null,
}));
const total = round2(lines.reduce((sum, l) => sum + Number(l.amount ?? 0), 0));
const total = round2(
lines.reduce((sum, l) => sum + Number(l.amount ?? 0), 0),
);
if (!(total > 0)) {
throw new BadRequestException("A memo must have a positive total.");
}
@@ -988,7 +1323,9 @@ export class BillingService {
subtotalAmount: total,
taxAmount: 0,
totalAmount: total,
...(settled ? { status: Freight.InvoiceStatus.Paid, dueAt: new Date() } : {}),
...(settled
? { status: Freight.InvoiceStatus.Paid, dueAt: new Date() }
: {}),
},
mg,
code,
@@ -999,7 +1336,11 @@ export class BillingService {
eimsReason: reason,
relatedInvoiceId: original.id,
...(settled
? { paidAmount: memo.totalAmount, balanceAmount: 0, paidAt: new Date() }
? {
paidAmount: memo.totalAmount,
balanceAmount: 0,
paidAt: new Date(),
}
: {}),
};
await mg.update(Invoice, memo.id, patch);
@@ -1059,7 +1400,7 @@ export class BillingService {
input.dueAt ??
new Date(
Date.now() +
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
);
const invoiceNumber = await this.nextInvoiceNumber(mg, code);
@@ -1580,9 +1921,9 @@ export class BillingService {
dueAt,
...(issuing
? {
status: Freight.InvoiceStatus.Pending,
issuedAt: invoice.issuedAt ?? new Date(),
}
status: Freight.InvoiceStatus.Pending,
issuedAt: invoice.issuedAt ?? new Date(),
}
: {}),
};
await mg.update(Invoice, { id: invoice.id }, patch);
@@ -1646,10 +1987,7 @@ export class BillingService {
const repo = this.dataSource.getRepository(Invoice);
const invoices = await repo.findBy({
paymentId,
status: In([
Freight.InvoiceStatus.Issued,
Freight.InvoiceStatus.Pending,
]),
status: In([Freight.InvoiceStatus.Issued, Freight.InvoiceStatus.Pending]),
});
for (const invoice of invoices) {
await repo.update(
@@ -1823,6 +2161,17 @@ export class BillingService {
.getRepository(Booking)
.update({ id: invoice.sourceId }, { pnrCode: billReference });
}
// Same reference, for an ad-hoc additional charge — its own column, since
// an AdditionalCharge doesn't own a Booking-scoped `pnrCode` and a booking
// can carry many of these at once.
if (
billReference &&
invoice.source === Freight.InvoiceSource.AdditionalCharge
) {
await this.dataSource
.getRepository(AdditionalCharge)
.update({ id: invoice.sourceId }, { paymentReference: billReference });
}
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
// billing must not simulate it. Kept for local demos only.

View File

@@ -1,4 +1,4 @@
import { InvoiceDocumentModel, InvoiceDocumentService } from "./invoice-document.service";
import { InvoiceDocumentModel, InvoiceDocumentService, sameCompanyName } from "./invoice-document.service";
const model = (over: Partial<InvoiceDocumentModel> = {}): InvoiceDocumentModel => ({
kind: "INVOICE",
@@ -87,3 +87,38 @@ describe("InvoiceDocumentService.buildThermalHtml", () => {
expect(html).not.toContain("right: 160px");
});
});
describe("sameCompanyName", () => {
it("treats eTrade's legal-suffix spellings as the same name", () => {
expect(sameCompanyName("ABIJOEL PLC", "ABIJOEL P L C")).toBe(true);
expect(
sameCompanyName(
"WISH TRADING PLC",
"WISH TRADING PRIVATE LIMITED COMPANY",
),
).toBe(true);
expect(
sameCompanyName("TUTA TRADING PLC", "TUTA TRADING ONE MEMBER PLC"),
).toBe(true);
});
it("keeps a genuinely different trade name distinct", () => {
// Real pairs from eTrade: the licence trades under a different name than
// the company registered under, which is exactly the row worth printing.
expect(
sameCompanyName("Cozy Coffee Grower and Exporter", "ABIJOEL P L C"),
).toBe(false);
expect(sameCompanyName("MENNA PRODUCTION", "ICOFFEE TRADING PLC")).toBe(
false,
);
expect(
sameCompanyName("YUNABEK TRADING PLC", "YUNABEK INVESTMENT PLC"),
).toBe(false);
});
it("is false when either side is missing, so no row is printed", () => {
expect(sameCompanyName("", "ABIJOEL P L C")).toBe(false);
expect(sameCompanyName(null, null)).toBe(false);
expect(sameCompanyName("ABIJOEL P L C", undefined)).toBe(false);
});
});

View File

@@ -48,6 +48,34 @@ function formatDate(value: unknown): string {
return value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
}
/**
* Is this trade name just the company name again?
*
* Compared loosely on purpose: eTrade spells the same legal suffix as "PLC",
* "P L C" and "PRIVATE LIMITED COMPANY", and pads names with double spaces, so
* an exact comparison would call two spellings of one name different and print
* a redundant row. Used only to decide whether a trade-name row is worth
* showing — never to decide that two businesses ARE the same.
*/
export function sameCompanyName(
a: string | null | undefined,
b: string | null | undefined,
): boolean {
const norm = (v: string | null | undefined) =>
(v ?? "")
.toUpperCase()
.replace(/[.,]/g, "")
.replace(/\s+/g, " ")
.trim()
.replace(/\bPRIVATE LIMITED COMPANY\b/g, "PLC")
.replace(/\bP L C\b/g, "PLC")
.replace(/\bONE (MEMBER|PERSON) PLC\b/g, "PLC")
.replace(/\s+/g, " ")
.trim();
const left = norm(a);
return left !== "" && left === norm(b);
}
/** One billed line on the document (charge type / fee type agnostic). */
export interface InvoiceDocumentLine {
description: string | null;
@@ -309,7 +337,11 @@ export class InvoiceDocumentService {
let y = 700;
const colX = [36, 300];
const colW = 250;
model.summary.slice(0, 16).forEach((row, i) => {
// 20, not 16: a booking invoice already fills 16 rows with every optional one present
// (buyer trade name, buyer VAT, seller TIN/VAT, IRN, PNR) and the transaction ref is the
// 17th — the old cap silently dropped whichever row landed last. Still fits: 20 rows end
// at y=423, leaving the line-item table its full run down to the y<190 cut-off.
model.summary.slice(0, 20).forEach((row, i) => {
const x = colX[i % 2];
if (i % 2 === 0 && i > 0) y -= 27;
ops.push(textOp((row.label ?? "").toUpperCase(), x, y, 7, "F1", PdfColor.gray));

View File

@@ -0,0 +1,55 @@
import { plainToInstance } from "class-transformer";
import { validateSync } from "class-validator";
import { FilterInvoiceDto } from "./filter-invoice.dto";
/**
* The list endpoint runs under `forbidNonWhitelisted`, so every param the
* backoffice filter bar sends has to survive transform + validation here or
* the whole request 400s. The CSV filters are the fragile part: they arrive as
* one string and must come out as a validated array.
*/
const parse = (query: Record<string, string>) => {
const dto = plainToInstance(FilterInvoiceDto, query);
return { dto, errors: validateSync(dto).map((e) => e.property) };
};
describe("FilterInvoiceDto", () => {
it("accepts the full filter-bar query and splits the CSV filters", () => {
const { dto, errors } = parse({
page: "2",
pageSize: "10",
search: "INV-2026",
statuses: "PENDING,OVERDUE",
sources: "booking,warehouse",
types: "PREPAID,WAGON_CANCEL_FEE",
eimsStatuses: "NOT_SUBMITTED",
currency: "etb",
issuedFrom: "2026-08-01T00:00:00.000Z",
issuedTo: "2026-08-20T20:59:59.999Z",
dueFrom: "2026-08-01T00:00:00.000Z",
dueTo: "2026-09-01T20:59:59.999Z",
minAmount: "100",
maxAmount: "5000",
hasBalance: "true",
overdue: "false",
sortBy: "balanceAmount",
sortOrder: "asc",
});
expect(errors).toEqual([]);
expect(dto.statuses).toEqual(["PENDING", "OVERDUE"]);
expect(dto.sources).toEqual(["booking", "warehouse"]);
expect(dto.types).toEqual(["PREPAID", "WAGON_CANCEL_FEE"]);
expect(dto.currency).toBe("ETB");
expect(dto.minAmount).toBe(100);
expect(dto.hasBalance).toBe(true);
expect(dto.overdue).toBe(false);
expect(dto.sortOrder).toBe("ASC");
});
it("rejects a value outside the enum and an unsortable column", () => {
expect(parse({ statuses: "PENDING,NOT_A_STATUS" }).errors).toEqual(["statuses"]);
expect(parse({ sortBy: "eimsIrn" }).errors).toEqual(["sortBy"]);
});
});

View File

@@ -2,14 +2,44 @@ import { Freight } from "@edr/types";
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import {
IsArray,
IsBoolean,
IsDateString,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
IsUUID,
Min,
} from "class-validator";
import { EimsInvoiceStatus } from "../../eims/eims-registration.types";
import { INVOICE_PAYMENT_METHODS } from "../invoice-settlement.util";
/** Columns the invoice list may be ordered by -> their query-builder expression. */
export const INVOICE_SORT_COLUMNS: Record<string, string> = {
issuedAt: "invoice.issuedAt",
dueAt: "invoice.dueAt",
createdAt: "invoice.createdAt",
totalAmount: "invoice.totalAmount",
balanceAmount: "invoice.balanceAmount",
invoiceNumber: "invoice.invoiceNumber",
};
/** `?statuses=A,B` -> `["A","B"]`. A bare value stays a one-element list. */
const csv = ({ value }: { value: unknown }) =>
typeof value === "string"
? value
.split(",")
.map((v) => v.trim())
.filter(Boolean)
: value;
const bool = ({ value }: { value: unknown }) => value === "true" || value === true;
const num = ({ value }: { value: unknown }) => Number(value);
export class FilterInvoiceDto {
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@@ -40,10 +70,122 @@ export class FilterInvoiceDto {
@IsIn(Object.values(Freight.InvoiceStatus))
status?: Freight.InvoiceStatus;
/** Manual-payments worklist only: restrict to one currency. */
/**
* Multi-select status (`?statuses=PENDING,OVERDUE`). ANDed with `status`
* when both are sent, so the single-status worklists keep their meaning.
*/
@ApiPropertyOptional({ isArray: true, enum: Freight.InvoiceStatus })
@IsOptional()
@Transform(csv)
@IsArray()
@IsIn(Object.values(Freight.InvoiceStatus), { each: true })
statuses?: Freight.InvoiceStatus[];
/** Originating subsystem (`booking`, `warehouse`, `shipping_line_credit`, …). */
@ApiPropertyOptional({ isArray: true, enum: Freight.InvoiceSource })
@IsOptional()
@Transform(csv)
@IsArray()
@IsIn(Object.values(Freight.InvoiceSource), { each: true })
sources?: Freight.InvoiceSource[];
/**
* What the invoice bills for (`?types=PREPAID,WAGON_CANCEL_FEE`). Free-form
* like `paymentMethods`: every billing source mints its own `type` string, so
* an `IsIn` here would silently drop a real value.
*/
@ApiPropertyOptional({ isArray: true, example: ["PREPAID"] })
@IsOptional()
@Transform(csv)
@IsArray()
@IsString({ each: true })
types?: string[];
/** MoR filing state — Finance's "what still needs registering" cut. */
@ApiPropertyOptional({ isArray: true, enum: EimsInvoiceStatus })
@IsOptional()
@Transform(csv)
@IsArray()
@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()
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
@IsIn(["USD", "ETB"])
currency?: "USD" | "ETB";
@ApiPropertyOptional({ description: "Issued at or after this instant (ISO)." })
@IsOptional()
@IsDateString()
issuedFrom?: string;
@ApiPropertyOptional({ description: "Issued at or before this instant (ISO)." })
@IsOptional()
@IsDateString()
issuedTo?: string;
@ApiPropertyOptional({ description: "Due at or after this instant (ISO)." })
@IsOptional()
@IsDateString()
dueFrom?: string;
@ApiPropertyOptional({ description: "Due at or before this instant (ISO)." })
@IsOptional()
@IsDateString()
dueTo?: string;
/** Total amount bounds, in the invoice's own currency — pair with `currency`. */
@ApiPropertyOptional()
@IsOptional()
@Transform(num)
@IsNumber()
minAmount?: number;
@ApiPropertyOptional()
@IsOptional()
@Transform(num)
@IsNumber()
maxAmount?: number;
@ApiPropertyOptional({ description: "Only invoices with an outstanding balance." })
@IsOptional()
@Transform(bool)
@IsBoolean()
hasBalance?: boolean;
/**
* Outstanding AND past its due date, computed rather than read off `status`:
* nothing sweeps PENDING rows into OVERDUE, so the status alone under-reports.
*/
@ApiPropertyOptional({ description: "Only invoices outstanding past their due date." })
@IsOptional()
@Transform(bool)
@IsBoolean()
overdue?: boolean;
@ApiPropertyOptional({ enum: Object.keys(INVOICE_SORT_COLUMNS), default: "issuedAt" })
@IsOptional()
@IsIn(Object.keys(INVOICE_SORT_COLUMNS))
sortBy?: string;
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "DESC" })
@IsOptional()
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
@IsIn(["ASC", "DESC"])
sortOrder?: "ASC" | "DESC";
}

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