Merge branch 'dev' into reschedule
BIN
.portal-flows-assets/00-invoice-detail-pay.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
.portal-flows-assets/01-invoices-due-full.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
.portal-flows-assets/02-login-full.png
Normal file
|
After Width: | Height: | Size: 673 KiB |
BIN
.portal-flows-assets/03-hager-invoices-empty-full.png
Normal file
|
After Width: | Height: | Size: 223 KiB |
BIN
.portal-flows-assets/04-contracts-list-full.png
Normal file
|
After Width: | Height: | Size: 272 KiB |
BIN
.portal-flows-assets/05-home-full.png
Normal file
|
After Width: | Height: | Size: 270 KiB |
BIN
.portal-flows-assets/06-contract-detail-signed-full.png
Normal file
|
After Width: | Height: | Size: 362 KiB |
BIN
.portal-flows-assets/07-contract-step1-full.png
Normal file
|
After Width: | Height: | Size: 200 KiB |
BIN
.portal-flows-assets/08-contract-step1-filled-full.png
Normal file
|
After Width: | Height: | Size: 234 KiB |
BIN
.portal-flows-assets/09-contract-step2-full.png
Normal file
|
After Width: | Height: | Size: 237 KiB |
BIN
.portal-flows-assets/10-contract-step3-review-full.png
Normal file
|
After Width: | Height: | Size: 261 KiB |
BIN
.portal-flows-assets/11-contract-duplicate-blocked-full.png
Normal file
|
After Width: | Height: | Size: 230 KiB |
BIN
.portal-flows-assets/11b-quotation-approve-full.png
Normal file
|
After Width: | Height: | Size: 386 KiB |
BIN
.portal-flows-assets/13-bookings-list-full.png
Normal file
|
After Width: | Height: | Size: 198 KiB |
BIN
.portal-flows-assets/14-booking-documents-modal-full.png
Normal file
|
After Width: | Height: | Size: 289 KiB |
BIN
.portal-flows-assets/15-nati-contracts-list-full.png
Normal file
|
After Width: | Height: | Size: 378 KiB |
BIN
.portal-flows-assets/16-nati-contracts-list-actions.png
Normal file
|
After Width: | Height: | Size: 326 KiB |
BIN
.portal-flows-assets/17-initiate-booking-confirm.png
Normal file
|
After Width: | Height: | Size: 291 KiB |
BIN
.portal-flows-assets/18-nati-bookings-list-actions.png
Normal file
|
After Width: | Height: | Size: 343 KiB |
BIN
.portal-flows-assets/19-complete-booking-cargo-full.png
Normal file
|
After Width: | Height: | Size: 222 KiB |
312
.portal-flows-assets/portal-flows.html
Normal 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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>
|
||||
25
.portal-flows-assets/render.mjs
Normal 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
BIN
EDR-Freight-Priority-Flows-Portal.pdf
Normal file
BIN
EDR-Freight-Priority-Flows.pdf
Normal file
BIN
EDR-Freight-User-Guide.pdf
Normal file
BIN
INV-20260812-00005-QR.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
INV-20260812-00005.pdf
Normal 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).
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
339
apps/edr-freight-api/scripts/run-legboard-tests.ts
Normal 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); });
|
||||
236
apps/edr-freight-api/scripts/run-s45-scenario.ts
Normal 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); });
|
||||
362
apps/edr-freight-api/scripts/run-wagon-gate-tests.ts
Normal 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);
|
||||
});
|
||||
96
apps/edr-freight-api/scripts/seed-s45-scenario.cjs
Normal 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); });
|
||||
142
apps/edr-freight-api/scripts/seed-wagon-gate-testcases.cjs
Normal 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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
29
apps/edr-freight-api/src/common/dto/id-list.transform.ts
Normal 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;
|
||||
});
|
||||
32
apps/edr-freight-api/src/common/dto/page-size-cap.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
101
apps/edr-freight-api/src/common/freight-jwt.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
assertCanApproveContractStep,
|
||||
canEditContractStep,
|
||||
collectPermissionKeys,
|
||||
collectPositionTypeKeys,
|
||||
hasFreightPermission,
|
||||
setPositionTypePermissionResolver,
|
||||
} from './freight-permission.util';
|
||||
@@ -121,3 +122,66 @@ describe('collectPermissionKeys — position-type grants', () => {
|
||||
expect(hasFreightPermission(direct, CLEARANCE)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* IAM lets an employee hold several positions, but the vendored `JwtGuard`
|
||||
* collapses `employee.positions[]` down to a single `employee.position` and
|
||||
* drops the rest — so staff on two posts resolved to one post's permissions
|
||||
* and every check on the other rejected them. `FreightJwtGuard` restores the
|
||||
* full list as `employee.positions`; these cover the union that depends on it.
|
||||
*/
|
||||
describe('multiple positions', () => {
|
||||
// Shaped like the real two-post employee: GL chief AND GL director.
|
||||
const twoPost = {
|
||||
employee: {
|
||||
// What the vendored guard leaves behind — one of the two, arbitrarily.
|
||||
position: {
|
||||
positionType: { key: 'djibouti-gl-chief' },
|
||||
permissions: [{ key: FREIGHT_PERMS.contracts.view }],
|
||||
},
|
||||
// What FreightJwtGuard puts back.
|
||||
positions: [
|
||||
{
|
||||
positionType: { key: 'djibouti-gl-chief' },
|
||||
permissions: [{ key: FREIGHT_PERMS.contracts.view }],
|
||||
},
|
||||
{
|
||||
positionType: { key: 'djibouti-gl-director' },
|
||||
permissions: [{ key: FREIGHT_PERMS.bookings.view }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
it('unions permissions across every position', () => {
|
||||
const keys = collectPermissionKeys(twoPost);
|
||||
expect(keys).toContain(FREIGHT_PERMS.contracts.view);
|
||||
expect(keys).toContain(FREIGHT_PERMS.bookings.view);
|
||||
});
|
||||
|
||||
it('grants the secondary position’s permission, not just the first', () => {
|
||||
expect(hasFreightPermission(twoPost, FREIGHT_PERMS.bookings.view)).toBe(true);
|
||||
});
|
||||
|
||||
it('answers to both position types', () => {
|
||||
expect(collectPositionTypeKeys(twoPost)).toEqual(
|
||||
expect.arrayContaining(['djibouti-gl-chief', 'djibouti-gl-director']),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not double-count the position the guard also left singular', () => {
|
||||
const keys = collectPermissionKeys(twoPost);
|
||||
expect(keys.filter((k) => k === FREIGHT_PERMS.contracts.view)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('still resolves the single position when the array is absent', () => {
|
||||
// A request that skipped FreightJwtGuard must degrade to the old behaviour,
|
||||
// not to no permissions at all.
|
||||
const onePost = {
|
||||
employee: {
|
||||
position: { permissions: [{ key: FREIGHT_PERMS.contracts.view }] },
|
||||
},
|
||||
};
|
||||
expect(hasFreightPermission(onePost, FREIGHT_PERMS.contracts.view)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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)])),
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
275
apps/edr-freight-api/src/config/mor-location.resolver.spec.ts
Normal 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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
255
apps/edr-freight-api/src/config/mor-location.resolver.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
1181
apps/edr-freight-api/src/config/mor-locations.data.ts
Normal 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
|
||||
? {
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;`);
|
||||
}
|
||||
}
|
||||
@@ -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 "Nagad–Mojo multimodal container 122,010 t" and
|
||||
* "Nagad–Mojo 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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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"`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
})
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
]),
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
|
||||