Merge branch 'dev' of https://github.com/Tria-plc/edr-platform into feature/group-booking
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');
|
||||
@@ -1,5 +1,5 @@
|
||||
import { applyDecorators, UseGuards } from '@nestjs/common';
|
||||
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
import { FreightJwtGuard } from './freight-jwt.guard';
|
||||
|
||||
import {
|
||||
FreightPermissionGuard,
|
||||
@@ -11,7 +11,7 @@ import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
|
||||
export const BookingStaff = (permission: string | string[]) =>
|
||||
applyDecorators(
|
||||
UseGuards(
|
||||
JwtGuard,
|
||||
FreightJwtGuard,
|
||||
FreightPermissionGuard(
|
||||
Array.isArray(permission) ? permission : [permission],
|
||||
),
|
||||
@@ -26,11 +26,11 @@ export const BookingStaff = (permission: string | string[]) =>
|
||||
* BookingStaff(<view key>) or MixedAudience(); kept for routes not yet swept.
|
||||
*/
|
||||
export const StaffReference = () =>
|
||||
applyDecorators(UseGuards(JwtGuard, FreightPermissionGuard([])));
|
||||
applyDecorators(UseGuards(FreightJwtGuard, FreightPermissionGuard([])));
|
||||
|
||||
/** Portal routes: customer accounts only; ownership scoping stays in services. */
|
||||
export const PortalCustomer = () =>
|
||||
applyDecorators(UseGuards(JwtGuard, PortalCustomerGuard));
|
||||
applyDecorators(UseGuards(FreightJwtGuard, PortalCustomerGuard));
|
||||
|
||||
/**
|
||||
* Routes both audiences call (sign, shared document reads, handover): staff
|
||||
@@ -40,7 +40,7 @@ export const PortalCustomer = () =>
|
||||
export const MixedAudience = (permission: string | string[]) =>
|
||||
applyDecorators(
|
||||
UseGuards(
|
||||
JwtGuard,
|
||||
FreightJwtGuard,
|
||||
MixedAudienceGuard(
|
||||
Array.isArray(permission) ? permission : [permission],
|
||||
),
|
||||
@@ -95,6 +95,24 @@ export const TrainSchedulingLoad = () =>
|
||||
export const TrainSchedulingUnload = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.unload);
|
||||
|
||||
/**
|
||||
* Per-station loading/unloading time windows — the four buttons are four
|
||||
* permissions so start and end can be granted to different people. The same
|
||||
* endpoint that records a click also edits it (explicit `at`), so each
|
||||
* permission covers editing its own timestamp too.
|
||||
*/
|
||||
export const TrainSchedulingLoadingStart = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.loadingStart);
|
||||
|
||||
export const TrainSchedulingLoadingEnd = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.loadingEnd);
|
||||
|
||||
export const TrainSchedulingUnloadingStart = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.unloadingStart);
|
||||
|
||||
export const TrainSchedulingUnloadingEnd = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.unloadingEnd);
|
||||
|
||||
export const TrainSchedulingCancel = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.cancel);
|
||||
|
||||
|
||||
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)])),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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,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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
* humanized handler name where a route has none.
|
||||
*
|
||||
* Excludes the AI Assist and Account entities.
|
||||
* Generated from the controllers under src/ — 517 endpoints.
|
||||
* Generated from the controllers under src/ — 528 endpoints.
|
||||
*/
|
||||
/** [title, method, entity] for one auditable route. */
|
||||
export type AuditEndpointMeta = readonly [title: string, method: string, entity: string];
|
||||
@@ -38,6 +38,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"POST /api/bookings/:id/clearance/draft-declaration": ["GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/draft-declaration/accept": ["Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/draft-declaration/change": ["Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/draft-declaration/skip": ["GL ET skips the draft-declaration round: no estimate is sent to the customer, the real declaration is filed directly and duty & tax passes by default", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/duty": ["GL ET sets duty/tax on booking with notice attachment", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/duty-slip": ["Customer uploads duty/tax payment slip on booking", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/export-release": ["Confirm Booking Export Release", "POST", "Booking"],
|
||||
@@ -51,7 +52,12 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"POST /api/bookings/:id/clearance/charges/port-document": ["GL Djibouti uploads the port-charges document", "POST", "Booking"],
|
||||
"PATCH /api/bookings/:id/clearance/charges/:chargeId/bill": ["GL Ethiopia sets or revises a clearance charge's amount + currency", "PATCH", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/charges/:chargeId/send": ["GL Ethiopia issues the clearance charge invoice to the customer", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/charges/:chargeId/accept": ["Customer accepts a proposed clearance charge — issues the payable invoice and locks the charge", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/charges/:chargeId/reject": ["Customer rejects a proposed clearance charge with a reason — GL Ethiopia revises and re-sends", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/charges/miscellaneous": ["GL Ethiopia creates the miscellaneous clearance charge", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/additional-charges": ["Finance raises a new additional charge — draft, or send to the customer immediately", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/additional-charges/:chargeId/send": ["Issue the draft charge's payable invoice and notify the customer", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/additional-charges/:chargeId/cancel": ["Withdraw a draft or unpaid additional charge", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/ro-amendment": ["Request Booking RO Amendment", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration", "POST", "Booking"],
|
||||
@@ -73,7 +79,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"POST /api/bookings/:id/documents": ["Upload documents for a booking (DRAFT only)", "POST", "Booking"],
|
||||
"PATCH /api/bookings/:id/export-handover-mode": ["Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first", "PATCH", "Booking"],
|
||||
"POST /api/bookings/:id/generate-grn": ["Generate a GRN over the received containers (all received, or a subset) — one GRN per batch", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/generate-price": ["Generate price preview (DRAFT or CHANGES_REQUESTED)", "POST", "Booking"],
|
||||
// "POST /api/bookings/:id/generate-price": ["Generate price preview (DRAFT or CHANGES_REQUESTED)", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/government-expedite": ["Expedite government booking to PAID / ELIGIBLE for scheduling", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/marketing/approve": ["Staff contract signature and fully execute (use contract/sign STAFF preferred)", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/operation/review": ["Operations reviews an operation request: ACCEPT (→ batch pool),", "POST", "Booking"],
|
||||
@@ -85,7 +91,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"POST /api/bookings/:id/staff/request-changes": ["Staff return booking for customer updates", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/submit": ["Customer submit booking", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/wagon-cancellations": ["Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/wagon-cancellations/preview": ["Preview the fee/credit of a partial wagon cancellation (no writes)", "POST", "Booking"],
|
||||
// "POST /api/bookings/:id/wagon-cancellations/preview": ["Preview the fee/credit of a partial wagon cancellation (no writes)", "POST", "Booking"],
|
||||
"POST /api/bookings/wagon-cancellations/:cancellationId/rebook": ["Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)", "POST", "Booking"],
|
||||
"POST /api/bookings/wagon-cancellations/:cancellationId/withdraw": ["Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)", "POST", "Booking"],
|
||||
"POST /api/bookings/consolidation-approvals/:approvalId/approve": ["Approve a shared wagon: both bookings leave the gate and continue to Operations together.", "POST", "Booking"],
|
||||
@@ -207,7 +213,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"POST /api/contracts/:id/staff/request-changes": ["Staff return contract for customer updates", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/submit": ["Customer submit contract (freezes contract_rate_snapshots)", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/suspend": ["Staff freeze a signed contract (reversible, any post-signature step)", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/validate-shipment": ["Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created)", "POST", "Contract"],
|
||||
// "POST /api/contracts/:id/validate-shipment": ["Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created)", "POST", "Contract"],
|
||||
"POST /api/contracts/booking-requests/:reqId/accept": ["GL marks a shipment request accepted + links the created booking", "POST", "Contract"],
|
||||
"POST /api/contracts/booking-requests/:reqId/cancel": ["Customer cancels their own pending shipment request", "POST", "Contract"],
|
||||
"POST /api/contracts/booking-requests/:reqId/reject": ["GL rejects a shipment request", "POST", "Contract"],
|
||||
@@ -240,7 +246,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"PUT /api/contract-templates/:code/articles": ["Replace the full ordered article list (used for reorder)", "PUT", "Contract Template"],
|
||||
"PATCH /api/contract-templates/:code/articles/:articleId": ["Update an article's title or body", "PATCH", "Contract Template"],
|
||||
"DELETE /api/contract-templates/:code/articles/:articleId": ["Remove an article from the template", "DELETE", "Contract Template"],
|
||||
"POST /api/contract-templates/:code/preview": ["Render an HTML preview of the template against mock contract data", "POST", "Contract Template"],
|
||||
// "POST /api/contract-templates/:code/preview": ["Render an HTML preview of the template against mock contract data", "POST", "Contract Template"],
|
||||
|
||||
// Driver
|
||||
"POST /api/drivers": ["Create a new driver", "POST", "Driver"],
|
||||
@@ -266,6 +272,8 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"POST /api/invoices/:id/eims/receipt/sales": ["Register a sales receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"],
|
||||
"POST /api/invoices/:id/eims/receipt/withholding": ["Register a withholding receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"],
|
||||
"POST /api/invoices/eims/bulk-cancel": ["Cancel multiple invoices", "POST", "EIMS Invoice"],
|
||||
"POST /api/invoices/eims/bulk-register": ["Submit multiple invoices to MoR EIMS in one call. Asynchronous — this only confirms MoR", "POST", "EIMS Invoice"],
|
||||
"POST /api/eims/webhook/bulk-register": ["EIMS bulk-register webhook callback (MoR reports per-invoice results)", "POST", "EIMS Invoice"],
|
||||
|
||||
// Exchange Setting
|
||||
"PATCH /api/exchange-settings": ["Set the USD→ETB fallback by hand (used only while CBE is unreachable)", "PATCH", "Exchange Setting"],
|
||||
@@ -373,6 +381,14 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"PATCH /api/notifications/:id/read": ["Mark one of my notifications as read", "PATCH", "Notification Inbox"],
|
||||
"POST /api/notifications/read-all": ["Mark all my notifications as read", "POST", "Notification Inbox"],
|
||||
|
||||
// Operations Standard
|
||||
"PATCH /api/operations-standards": ["Change one or more operating standards", "PATCH", "Operations Standard"],
|
||||
|
||||
// Operations Target
|
||||
"POST /api/operations-targets": ["Create a planned target", "POST", "Operations Target"],
|
||||
"PATCH /api/operations-targets/:id": ["Update a planned target", "PATCH", "Operations Target"],
|
||||
"DELETE /api/operations-targets/:id": ["Soft-delete a planned target", "DELETE", "Operations Target"],
|
||||
|
||||
// Organization User
|
||||
"PUT /api/backoffice/organizations/:orgId/employee-users/:userId/roles": ["Replace org-scoped roles assigned to an employee user", "PUT", "Organization User"],
|
||||
"POST /api/backoffice/organizations/:orgId/users": ["Create an organization user without assigning positions", "POST", "Organization User"],
|
||||
@@ -445,7 +461,8 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
// Two controllers register this same path; Nest serves whichever module loads first.
|
||||
"POST /api/train-scheduling/schedules/:id/maintenance": ["Reschedule train for maintenance (new departure + rebalance)", "POST", "Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/reschedule/execute": ["Execute a confirmed reschedule plan", "POST", "Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/reschedule/preview": ["Preview reschedule / government preempt plan", "POST", "Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/reschedule/maintenance": ["Reschedule train for maintenance (new departure + rebalance)", "POST", "Schedule"],
|
||||
// "POST /api/train-scheduling/schedules/:id/reschedule/preview": ["Preview reschedule / government preempt plan", "POST", "Schedule"],
|
||||
|
||||
// Service Type
|
||||
"POST /api/service-types": ["Create a service type", "POST", "Service Type"],
|
||||
@@ -463,7 +480,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
// Shipping Line Booking
|
||||
"POST /api/shipping-line-bookings/initiate": ["Initiate a bare booking (no contract). Starts at AWAITING_DOCUMENTS so the shipping line can upload its documents for Operations to approve.", "POST", "Shipping Line Booking"],
|
||||
"POST /api/shipping-line-bookings/:id/cancel": ["Cancel one of the signed-in shipping line's own bookings. Allowed only before the booking is priced.", "POST", "Shipping Line Booking"],
|
||||
"POST /api/shipping-line-bookings/:id/price-preview": ["Authoritative price quote for the completion payload — same compute as /complete, saved as the booking's breakdown + rate snapshots (refreshed on every re-preview). Persists nothing else.", "POST", "Shipping Line Booking"],
|
||||
// "POST /api/shipping-line-bookings/:id/price-preview": ["Authoritative price quote for the completion payload — same compute as /complete, saved as the booking's breakdown + rate snapshots (refreshed on every re-preview). Persists nothing else.", "POST", "Shipping Line Booking"],
|
||||
"POST /api/shipping-line-bookings/:id/complete": ["Complete an approved (CLEARANCE_READY) booking: cargo + binding shipment day.", "POST", "Shipping Line Booking"],
|
||||
|
||||
// Shipping Line Credit
|
||||
@@ -510,6 +527,8 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"PATCH /api/train-builder/:id/details": ["Edit the train's name and fixed import/export run numbers", "PATCH", "Train Build"],
|
||||
"PUT /api/train-builder/:id/locomotives": ["Replace the locomotive set (minimum 1, same yard)", "PUT", "Train Build"],
|
||||
"POST /api/train-builder/:id/reorder-wagons": ["Persist a drag-reorder of the full consist", "POST", "Train Build"],
|
||||
"PATCH /api/train-builder/:id/wagons/:wagonId/yard": ["Move one coupled wagon to another yard — refused while any live schedule has the wagon allocated", "PATCH", "Train Build"],
|
||||
"PATCH /api/train-builder/:id/wagons/yard": ["Move several coupled wagons to another yard in one transaction — refused outright if any is allocated to a live schedule", "PATCH", "Train Build"],
|
||||
"POST /api/train-builder/:id/wagons": ["Append AVAILABLE wagons from the train's yard to the consist", "POST", "Train Build"],
|
||||
"DELETE /api/train-builder/:id/wagons/:wagonId": ["Detach one wagon from the consist", "DELETE", "Train Build"],
|
||||
"POST /api/train-builder/:id/wagons/:wagonId/maintenance": ["Detach one wagon and move it to MAINTENANCE status", "POST", "Train Build"],
|
||||
@@ -520,16 +539,16 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"POST /api/train-scheduling/bookings/:bookingId/expire": ["Staff: expire a reservation and free its capacity", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/bookings/:bookingId/mark-paid": ["Staff: mark a reserved booking paid and allocate it now", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/bookings/:bookingId/move-schedule": ["Re-point a booking to another OPEN same-route schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/bulk/preview": ["Preview a bulk train schedule", "POST", "Train Schedule"],
|
||||
// "POST /api/train-scheduling/bulk/preview": ["Preview a bulk train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/bulk/schedules": ["Create a bulk train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/bulk/schedules/:id/assign-bookings": ["Assign bulk bookings to a train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/bulk/schedules/:id/cancel": ["Cancel bulk train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/container/preview": ["Preview a container train schedule", "POST", "Train Schedule"],
|
||||
// "POST /api/train-scheduling/container/preview": ["Preview a container train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/container/schedules": ["Create a container train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/container/schedules/:id/assign-bookings": ["Assign container bookings to a train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/container/schedules/:id/cancel": ["Cancel container train schedule", "POST", "Train Schedule"],
|
||||
"PATCH /api/train-scheduling/global-rules": ["Update global train scheduling rules (singleton)", "PATCH", "Train Schedule"],
|
||||
"POST /api/train-scheduling/preview": ["Preview a mixed-capable train schedule", "POST", "Train Schedule"],
|
||||
// "POST /api/train-scheduling/preview": ["Preview a mixed-capable train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/adjust-consist": ["Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/arrive": ["Mark a dispatched train arrived (move assets to destination yard, free assets)", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/assign-bookings": ["Assign bookings to a train schedule (mixed-capable)", "POST", "Train Schedule"],
|
||||
@@ -564,6 +583,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"DELETE /api/train-scheduling/schedules/:id/wagons/:trainSetWagonId": ["Remove an empty wagon slot from a train", "DELETE", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/wagons/:wagonId/move-load": ["Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)", "POST", "Train Schedule"],
|
||||
"PATCH /api/train-scheduling/schedules/:id/window-rule": ["Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens", "PATCH", "Train Schedule"],
|
||||
"PATCH /api/train-scheduling/schedules/:id/wagon-yards": ["Re-plan the yard this departure boards wagons from and/or cuts them at (schedule-only; physical yards untouched, dispatch requires alignment)", "PATCH", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/merge": ["Merge another train into this schedule: its wagons join this consist, a same-day schedule on it is absorbed, and the emptied train is deactivated", "POST", "Train Schedule"],
|
||||
"PATCH /api/train-scheduling/schedules/:id/checkpoints/:sequenceNo": ["Edit a logged leg", "PATCH", "Train Schedule"],
|
||||
|
||||
@@ -611,7 +631,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"POST /api/warehouse-allocation-rules": ["Create a warehouse allocation rule", "POST", "Warehouse"],
|
||||
"PATCH /api/warehouse-allocation-rules/:id": ["Update a warehouse allocation rule", "PATCH", "Warehouse"],
|
||||
"DELETE /api/warehouse-allocation-rules/:id": ["Delete a warehouse allocation rule", "DELETE", "Warehouse"],
|
||||
"POST /api/warehouse-allocation/preview": ["Preview the yard/warehouse/zone a booking would be allocated to", "POST", "Warehouse"],
|
||||
// "POST /api/warehouse-allocation/preview": ["Preview the yard/warehouse/zone a booking would be allocated to", "POST", "Warehouse"],
|
||||
"POST /api/warehouse-fee-rules": ["Create a storage / demurrage fee rule", "POST", "Warehouse"],
|
||||
"PATCH /api/warehouse-fee-rules/:id": ["Update a fee rule", "PATCH", "Warehouse"],
|
||||
"DELETE /api/warehouse-fee-rules/:id": ["Delete a fee rule", "DELETE", "Warehouse"],
|
||||
|
||||
@@ -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),
|
||||
]),
|
||||
];
|
||||
|
||||
|
||||
@@ -939,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,
|
||||
@@ -1014,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);
|
||||
|
||||
@@ -29,6 +29,7 @@ import { PaymentService } from "../payment/payment.service";
|
||||
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
|
||||
import {
|
||||
InvoiceDocumentModel,
|
||||
sameCompanyName,
|
||||
InvoiceDocumentService,
|
||||
pngDataUrl,
|
||||
} from "./documents/invoice-document.service";
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
applySettlement,
|
||||
invoicePaymentMethodExpr,
|
||||
round2,
|
||||
settlementReferences,
|
||||
} from "./invoice-settlement.util";
|
||||
import { InvoiceRepository } from "./invoice.repository";
|
||||
|
||||
@@ -267,7 +269,7 @@ export class BillingService {
|
||||
private readonly files: FilesService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly manualPaymentSettings: ManualPaymentSettingsService,
|
||||
) { }
|
||||
) {}
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -300,7 +302,9 @@ export class BillingService {
|
||||
});
|
||||
}
|
||||
if (filter.sources?.length) {
|
||||
qb.andWhere("invoice.source IN (:...sources)", { sources: filter.sources });
|
||||
qb.andWhere("invoice.source IN (:...sources)", {
|
||||
sources: filter.sources,
|
||||
});
|
||||
}
|
||||
if (filter.eimsStatuses?.length) {
|
||||
qb.andWhere("invoice.eimsStatus IN (:...eimsStatuses)", {
|
||||
@@ -326,7 +330,9 @@ export class BillingService {
|
||||
});
|
||||
}
|
||||
if (filter.issuedTo) {
|
||||
qb.andWhere("invoice.issuedAt <= :issuedTo", { issuedTo: filter.issuedTo });
|
||||
qb.andWhere("invoice.issuedAt <= :issuedTo", {
|
||||
issuedTo: filter.issuedTo,
|
||||
});
|
||||
}
|
||||
if (filter.dueFrom) {
|
||||
qb.andWhere("invoice.dueAt >= :dueFrom", { dueFrom: filter.dueFrom });
|
||||
@@ -589,21 +595,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;
|
||||
@@ -612,33 +625,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
|
||||
@@ -702,6 +757,7 @@ export class BillingService {
|
||||
} as OfflineUsdInvoiceRow;
|
||||
}),
|
||||
total,
|
||||
outstanding,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -848,7 +904,9 @@ export class BillingService {
|
||||
{
|
||||
label: "Wagons",
|
||||
value:
|
||||
booking.wagonsRequired != null ? String(booking.wagonsRequired) : null,
|
||||
booking.wagonsRequired != null
|
||||
? String(booking.wagonsRequired)
|
||||
: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -875,10 +933,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 }]
|
||||
@@ -907,11 +976,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
|
||||
@@ -921,7 +1003,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 {
|
||||
@@ -942,7 +1025,9 @@ export class BillingService {
|
||||
currency: l.currency,
|
||||
})),
|
||||
totals,
|
||||
qrImageUrl: invoice.eimsSignedQr ? pngDataUrl(invoice.eimsSignedQr) : null,
|
||||
qrImageUrl: invoice.eimsSignedQr
|
||||
? pngDataUrl(invoice.eimsSignedQr)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1201,7 +1286,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.");
|
||||
}
|
||||
@@ -1231,7 +1318,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,
|
||||
@@ -1242,7 +1331,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);
|
||||
@@ -1302,7 +1395,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);
|
||||
@@ -1823,9 +1916,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);
|
||||
@@ -1889,10 +1982,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(
|
||||
@@ -2069,7 +2159,10 @@ export class BillingService {
|
||||
// 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) {
|
||||
if (
|
||||
billReference &&
|
||||
invoice.source === Freight.InvoiceSource.AdditionalCharge
|
||||
) {
|
||||
await this.dataSource
|
||||
.getRepository(AdditionalCharge)
|
||||
.update({ id: invoice.sourceId }, { paymentReference: billReference });
|
||||
|
||||
@@ -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,50 @@
|
||||
import { settlementReferences } from "./invoice-settlement.util";
|
||||
|
||||
describe("settlementReferences", () => {
|
||||
it("returns the provider reference recorded on the invoice ledger", () => {
|
||||
expect(
|
||||
settlementReferences({
|
||||
payments: [{ reference: "FT26082700123" }],
|
||||
}),
|
||||
).toBe("FT26082700123");
|
||||
});
|
||||
|
||||
it("reads the linked gateway payment row when the ledger has no reference", () => {
|
||||
expect(
|
||||
settlementReferences({
|
||||
payments: [{ reference: null }],
|
||||
payment: { transactionId: "TB998877" },
|
||||
}),
|
||||
).toBe("TB998877");
|
||||
});
|
||||
|
||||
it("does not repeat a reference that both sources carry", () => {
|
||||
expect(
|
||||
settlementReferences({
|
||||
payments: [{ reference: "FT26082700123" }],
|
||||
payment: { transactionId: "FT26082700123" },
|
||||
}),
|
||||
).toBe("FT26082700123");
|
||||
});
|
||||
|
||||
it("lists every leg of a partially-then-fully paid invoice, oldest first", () => {
|
||||
expect(
|
||||
settlementReferences({
|
||||
payments: [{ reference: "SLIP-001" }, { reference: "FT26082700123" }],
|
||||
}),
|
||||
).toBe("SLIP-001, FT26082700123");
|
||||
});
|
||||
|
||||
it("drops the internal intent id the gateway path falls back to", () => {
|
||||
expect(
|
||||
settlementReferences({
|
||||
payments: [{ reference: "3f8a1c2e-9b4d-4a71-8c6e-2d5f7a9b1c30" }],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("is null for an unpaid invoice", () => {
|
||||
expect(settlementReferences({ payments: [] })).toBeNull();
|
||||
expect(settlementReferences({})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -74,3 +74,44 @@ export const INVOICE_PAYMENT_METHODS = [
|
||||
/** Settled at a gateway whose provider row is no longer linked. */
|
||||
"GATEWAY",
|
||||
] as const;
|
||||
|
||||
/** Anything shaped enough to read settlement references off. */
|
||||
interface SettlementReferenceSource {
|
||||
payments?: Array<{ reference?: string | null }> | null;
|
||||
payment?: { transactionId?: string | null } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A settlement reference is the PROVIDER's own transaction number, never ours.
|
||||
* The gateway path falls back to the intent id when a provider returns no txn
|
||||
* ref (`markInvoiceAsPaid`: `providerTxnId ?? paymentId`), and that id is a
|
||||
* uuid — an internal correlation key that means nothing to a payer holding a
|
||||
* bank slip, so it is dropped rather than printed. No provider's reference is
|
||||
* uuid-shaped: CBE sends `FT…`, telebirr/ebirr/waafi send digit strings.
|
||||
*/
|
||||
const INTERNAL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* Every provider transaction reference recorded against an invoice, oldest
|
||||
* first, joined for display — CBE's `FT…`, telebirr's receipt number, or the
|
||||
* bank-slip number a teller typed into a manual settlement. Null when nothing
|
||||
* identifiable was recorded.
|
||||
*
|
||||
* Reads BOTH sources because neither alone is complete: the invoice's own
|
||||
* ledger is the only record of manual settlements and of each leg of a
|
||||
* partially-paid invoice, while the linked `freight.payments` row is the only
|
||||
* place a provider txn id lands when it arrives after settlement (a webhook
|
||||
* that stamps `transactionId` on an already-settled intent). Deduped, since
|
||||
* the ordinary gateway path writes the same value to both.
|
||||
*/
|
||||
export function settlementReferences(
|
||||
invoice: SettlementReferenceSource,
|
||||
): string | null {
|
||||
const refs = [
|
||||
...(invoice.payments ?? []).map((p) => p.reference),
|
||||
invoice.payment?.transactionId,
|
||||
].filter(
|
||||
(ref): ref is string => Boolean(ref) && !INTERNAL_ID.test(ref as string),
|
||||
);
|
||||
return [...new Set(refs)].join(", ") || null;
|
||||
}
|
||||
|
||||
@@ -40,3 +40,70 @@ describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => {
|
||||
expect(cut.weightTons).toBeCloseTo(62.625, 3);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Odd-20ft credit rebook: the rebooked booking shares a wagon again, so GL
|
||||
* must pick the consolidation partner — no partner, no rebook; a partner
|
||||
* already paired elsewhere is refused.
|
||||
*/
|
||||
describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () => {
|
||||
const units = Array.from({ length: 3 }, (_, i) => ({
|
||||
containerSize: '20ft',
|
||||
containerNumber: `CONT${i}`,
|
||||
sealNumber: null,
|
||||
vgmTons: 10,
|
||||
isHazardous: false,
|
||||
isReefer: false,
|
||||
}));
|
||||
const row = {
|
||||
id: 'wc1',
|
||||
bookingId: 'b1',
|
||||
status: 'CREDIT_AVAILABLE',
|
||||
creditAmount: 100,
|
||||
cancelledQuantities: { bySize: { '20ft': 3 }, units },
|
||||
};
|
||||
const source = {
|
||||
id: 'b1',
|
||||
contractId: 'c1',
|
||||
paymentCurrency: 'USD',
|
||||
originYardId: 'y1',
|
||||
destinationYardId: 'y2',
|
||||
tradeDirection: 'IMPORT',
|
||||
};
|
||||
|
||||
const makeSvc = (partner?: unknown) => {
|
||||
const svc = Object.create(BookingWagonCancellationService.prototype) as Record<
|
||||
string,
|
||||
unknown
|
||||
> & {
|
||||
rebook(id: string, dto: unknown): Promise<unknown>;
|
||||
};
|
||||
svc.repo = { findById: async () => row };
|
||||
svc.bookingsRepository = {
|
||||
findById: async () => source,
|
||||
findByIdWithFiles: async () => partner ?? null,
|
||||
};
|
||||
return svc;
|
||||
};
|
||||
|
||||
it('refuses an odd-20ft rebook without a GL-picked partner', async () => {
|
||||
await expect(
|
||||
makeSvc().rebook('wc1', { scheduledDate: '2026-09-01' }),
|
||||
).rejects.toThrow(/pick a consolidation partner/i);
|
||||
});
|
||||
|
||||
it('refuses a partner that already shares a wagon', async () => {
|
||||
const paired = {
|
||||
id: 'p1',
|
||||
reference: 'BK-1',
|
||||
status: 'SUBMITTED',
|
||||
consolidationPartnerId: 'someone-else',
|
||||
};
|
||||
await expect(
|
||||
makeSvc(paired).rebook('wc1', {
|
||||
scheduledDate: '2026-09-01',
|
||||
partnerBookingId: 'p1',
|
||||
}),
|
||||
).rejects.toThrow(/already shares a wagon/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
import { Freight, NotificationAudience, NotificationType } from '@edr/types';
|
||||
import { DataSource, EntityManager, In, IsNull } from 'typeorm';
|
||||
@@ -126,6 +126,7 @@ export class BookingWagonCancellationService {
|
||||
@Inject(forwardRef(() => FirstMileService))
|
||||
private readonly firstMile: FirstMileService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly events: EventEmitter2,
|
||||
) {}
|
||||
|
||||
// ── T1: request ────────────────────────────────────────────────────────────
|
||||
@@ -590,6 +591,24 @@ export class BookingWagonCancellationService {
|
||||
this.logger.error(
|
||||
`Consolidation-lapse cancellation failed for paid booking ${payload.paidBookingId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
// A silent failure here leaves a PAID half-wagon booking boarding alone
|
||||
// (BK-2026-000201: no LIVE IMPORT 20ft CANCELLATION_FEE rate — the fee
|
||||
// pricing threw and the booking stayed PAID). Scream to staff so it is
|
||||
// fixed and the booking cancelled by hand instead of shipping.
|
||||
try {
|
||||
const failed = await this.bookingsRepository.findById(
|
||||
payload.paidBookingId,
|
||||
);
|
||||
if (failed) {
|
||||
this.notifyStaff(
|
||||
failed,
|
||||
'Consolidation-lapse cancellation FAILED — action needed',
|
||||
`${failed.reference}: its consolidation partner lapsed unpaid, but the automatic cancellation failed: ${err instanceof Error ? err.message : String(err)}. Fix the cause (usually a missing LIVE per-wagon CANCELLATION_FEE rate for this trade direction + container size), then cancel the whole booking manually so the fee is invoiced and its wagons are freed.`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Notification is best-effort — the error log above already fired.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -782,6 +801,26 @@ export class BookingWagonCancellationService {
|
||||
const createDto = this.buildRebookDto(row, dto.scheduledDate, dto.containers);
|
||||
// Same currency as the source booking — the credit is in it.
|
||||
createDto.paymentCurrency = source.paymentCurrency ?? undefined;
|
||||
|
||||
// An odd-20ft credit shares a wagon again on rebook. GL picks who — never
|
||||
// the auto-matcher (it could claim a partner behind GL's back), so the
|
||||
// create below runs with auto-consolidation off and the chosen partner is
|
||||
// linked once the booking exists and is PAID.
|
||||
const oddFt20 = this.creditFt20(row) % 2 === 1;
|
||||
let partner: Booking | null = null;
|
||||
if (oddFt20) {
|
||||
createDto.skipAutoConsolidation = true;
|
||||
if (!dto.partnerBookingId) {
|
||||
throw new BadRequestException(
|
||||
'This credit carries an odd 20ft container — pick a consolidation partner booking to share its wagon (see the rebook-partners list).',
|
||||
);
|
||||
}
|
||||
partner = await this.loadRebookPartner(
|
||||
source,
|
||||
dto.partnerBookingId,
|
||||
dto.scheduledDate,
|
||||
);
|
||||
}
|
||||
const created = await this.contractBooking.createUnderContract(
|
||||
source.contractId,
|
||||
createDto,
|
||||
@@ -814,12 +853,19 @@ export class BookingWagonCancellationService {
|
||||
`First-mile accept failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
await this.bookingBatch.ensurePaidBookingAllocated(newBookingId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Allocation failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
if (partner) {
|
||||
// Consolidated rebook: never allocate the half-wagon booking alone. It
|
||||
// rides PAID and the batch engine settles the pair atomically once the
|
||||
// partner's own invoice is paid.
|
||||
await this.pairRebookedBooking(newBookingId, partner);
|
||||
} else {
|
||||
try {
|
||||
await this.bookingBatch.ensurePaidBookingAllocated(newBookingId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Allocation failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = (await this.repo.update(row.id, {
|
||||
@@ -837,6 +883,142 @@ export class BookingWagonCancellationService {
|
||||
return { cancellation: updated, bookingId: newBookingId };
|
||||
}
|
||||
|
||||
/** Total 20ft units the credit carries (odd ⇒ the rebook shares a wagon again). */
|
||||
private creditFt20(row: BookingWagonCancellation): number {
|
||||
return Object.entries(row.cancelledQuantities?.bySize ?? {})
|
||||
.filter(([size]) => sizeFtOf(size) === 20)
|
||||
.reduce((sum, [, qty]) => sum + Number(qty || 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Partner candidates for rebooking an odd-20ft credit — what the GL rebook
|
||||
* form lists. Empty when the credit is even (no shared wagon) or spent.
|
||||
*/
|
||||
async rebookPartnerCandidates(
|
||||
cancellationId: string,
|
||||
scheduledDate: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
reference: string;
|
||||
companyName: string | null;
|
||||
status: string;
|
||||
scheduledDate: string | null;
|
||||
ft20Quantity: number;
|
||||
}>
|
||||
> {
|
||||
const row = await this.mustFind(cancellationId);
|
||||
if (row.status !== 'CREDIT_AVAILABLE') return [];
|
||||
if (this.creditFt20(row) % 2 === 0) return [];
|
||||
const source = await this.bookingsRepository.findById(row.bookingId);
|
||||
if (!source) return [];
|
||||
const rows = await this.bookingsRepository.findRebookConsolidationCandidates(
|
||||
source,
|
||||
new Date(scheduledDate),
|
||||
);
|
||||
return rows.map((b) => ({
|
||||
id: b.id,
|
||||
reference: b.reference,
|
||||
companyName: b.company?.name ?? null,
|
||||
status: b.status,
|
||||
scheduledDate: b.scheduledDate ? b.scheduledDate.toISOString() : null,
|
||||
ft20Quantity: (b.bookingContainers ?? [])
|
||||
.filter((line) => Number(line.containerType?.sizeFt) === 20)
|
||||
.reduce((sum, line) => sum + Number(line.quantity || 0), 0),
|
||||
}));
|
||||
}
|
||||
|
||||
/** The GL-picked partner, validated to actually fit the rebooked shared wagon. */
|
||||
private async loadRebookPartner(
|
||||
source: Booking,
|
||||
partnerId: string,
|
||||
scheduledDate: string,
|
||||
): Promise<Booking> {
|
||||
const partner = await this.bookingsRepository.findByIdWithFiles(partnerId);
|
||||
if (!partner) {
|
||||
throw new NotFoundException(`Partner booking ${partnerId} not found.`);
|
||||
}
|
||||
if (partner.consolidationPartnerId) {
|
||||
throw new ConflictException(
|
||||
`Booking ${partner.reference} already shares a wagon with another booking.`,
|
||||
);
|
||||
}
|
||||
if (!['SUBMITTED', 'PENDING_CONSOLIDATION'].includes(partner.status)) {
|
||||
throw new BadRequestException(
|
||||
`Booking ${partner.reference} cannot be consolidated (status ${partner.status}).`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
partner.originYardId !== source.originYardId ||
|
||||
partner.destinationYardId !== source.destinationYardId ||
|
||||
partner.tradeDirection !== source.tradeDirection
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Booking ${partner.reference} rides a different route/direction — it cannot share a wagon with this rebooking.`,
|
||||
);
|
||||
}
|
||||
const eatDay = (d: Date | string) =>
|
||||
new Date(d).toLocaleDateString('en-CA', { timeZone: 'Africa/Addis_Ababa' });
|
||||
if (!partner.scheduledDate || eatDay(partner.scheduledDate) !== eatDay(scheduledDate)) {
|
||||
throw new BadRequestException(
|
||||
`Booking ${partner.reference} is not booked for ${eatDay(scheduledDate)} — a shared wagon must board one train.`,
|
||||
);
|
||||
}
|
||||
const ft20 = (partner.bookingContainers ?? [])
|
||||
.filter((line) => Number(line.containerType?.sizeFt) === 20)
|
||||
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
|
||||
if (ft20 % 2 !== 1) {
|
||||
throw new BadRequestException(
|
||||
`Booking ${partner.reference} has no odd 20ft container — nothing to consolidate.`,
|
||||
);
|
||||
}
|
||||
return partner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Link the rebooked (already PAID) booking with the GL-picked partner. A
|
||||
* parked partner is resumed the way pairConsolidation would resume it —
|
||||
* but only the partner: the rebooked side's PAID status must survive, so
|
||||
* the link is written directly. The paired event then runs the partner's
|
||||
* deferred contract finalize (invoice → pay window); the shared wagon
|
||||
* boards once that invoice is paid.
|
||||
*/
|
||||
private async pairRebookedBooking(
|
||||
newBookingId: string,
|
||||
partner: Booking,
|
||||
): Promise<void> {
|
||||
// ponytail: validate-then-link without a row lock — a concurrent claim in
|
||||
// this window loses silently; move to pairConsolidationIfUnpaired-style
|
||||
// locking if it ever bites.
|
||||
const fresh = await this.dataSource.getRepository(Booking).findOne({
|
||||
where: { id: partner.id },
|
||||
select: { id: true, consolidationPartnerId: true, status: true },
|
||||
});
|
||||
if (!fresh || fresh.consolidationPartnerId) {
|
||||
throw new ConflictException(
|
||||
`Booking ${partner.reference} was claimed by another consolidation while rebooking — pick another partner.`,
|
||||
);
|
||||
}
|
||||
if (fresh.status === 'PENDING_CONSOLIDATION') {
|
||||
await this.dataSource.getRepository(Booking).update(partner.id, {
|
||||
status: partner.consolidationResumeStatus ?? 'SUBMITTED',
|
||||
consolidationResumeStatus: null,
|
||||
});
|
||||
}
|
||||
await this.bookingsRepository.linkConsolidationPartners(
|
||||
newBookingId,
|
||||
partner.id,
|
||||
);
|
||||
this.events.emit('booking.consolidation.paired', {
|
||||
bookingIds: [partner.id],
|
||||
});
|
||||
this.notifyCustomer(
|
||||
partner,
|
||||
'Consolidation partner found',
|
||||
`${partner.reference} now shares a wagon with a rebooked shipment. Pay your booking to board — the shared wagon ships once both halves are paid.`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── History ────────────────────────────────────────────────────────────────
|
||||
|
||||
list(filter: WagonCancellationListFilter) {
|
||||
|
||||
@@ -725,6 +725,30 @@ export class BookingsController {
|
||||
return this.wagonCancellationService.withdraw(cancellationId);
|
||||
}
|
||||
|
||||
@Get("wagon-cancellations/:cancellationId/rebook-partners")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Consolidation partner candidates for rebooking an odd-20ft credit on the given day (GL picks who shares the rebooked wagon)",
|
||||
})
|
||||
async listRebookPartners(
|
||||
@Param("cancellationId", ParseUUIDPipe) cancellationId: string,
|
||||
@Query("scheduledDate") scheduledDate: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertWagonCancellationActor(
|
||||
cancellationId,
|
||||
user,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationRebook,
|
||||
);
|
||||
if (!scheduledDate) {
|
||||
throw new BadRequestException("scheduledDate is required.");
|
||||
}
|
||||
return this.wagonCancellationService.rebookPartnerCandidates(
|
||||
cancellationId,
|
||||
scheduledDate,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("wagon-cancellations/:cancellationId/rebook")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
|
||||
@@ -377,6 +377,58 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidate partners for rebooking an odd-20ft cancellation credit: unpaired
|
||||
* odd-20ft bookings on the same route/direction riding the requested day —
|
||||
* SUBMITTED (committed direct booking) or parked PENDING_CONSOLIDATION.
|
||||
* Unlike {@link findManualConsolidationCandidates} this is not customs-only:
|
||||
* GL picks who shares the rebooked wagon whatever the contract kind.
|
||||
*/
|
||||
async findRebookConsolidationCandidates(
|
||||
booking: Booking,
|
||||
scheduledDate: Date,
|
||||
limit = 50,
|
||||
): Promise<Booking[]> {
|
||||
const rows = await this.repository
|
||||
.createQueryBuilder('b')
|
||||
.leftJoinAndSelect('b.bookingContainers', 'bc')
|
||||
.leftJoinAndSelect('bc.containerType', 'ct')
|
||||
.leftJoinAndSelect('b.company', 'company')
|
||||
.where('b.id != :bookingId', { bookingId: booking.id })
|
||||
.andWhere('b.consolidationPartnerId IS NULL')
|
||||
.andWhere('b.originYardId = :originYardId', {
|
||||
originYardId: booking.originYardId,
|
||||
})
|
||||
.andWhere('b.destinationYardId = :destinationYardId', {
|
||||
destinationYardId: booking.destinationYardId,
|
||||
})
|
||||
.andWhere('b.tradeDirection = :tradeDirection', {
|
||||
tradeDirection: booking.tradeDirection,
|
||||
})
|
||||
.andWhere('b.status IN (:...statuses)', {
|
||||
statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'],
|
||||
})
|
||||
// Same EAT booking day as the rebook — the pair shares one physical
|
||||
// wagon, so it must board one train.
|
||||
.andWhere(
|
||||
`DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`,
|
||||
{ bookingDate: scheduledDate },
|
||||
)
|
||||
.orderBy('b.createdAt', 'ASC')
|
||||
.take(limit)
|
||||
.getMany();
|
||||
|
||||
// Odd-20ft test in memory (two 20ft per wagon: odd + odd = whole wagons).
|
||||
return rows.filter((row) => {
|
||||
const lines = row.bookingContainers ?? [];
|
||||
if (lines.length === 0) return false;
|
||||
const ft20 = lines
|
||||
.filter((line) => Number(line.containerType?.sizeFt) === 20)
|
||||
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
|
||||
return ft20 % 2 === 1;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find another booking whose container quantity complements this one to fill whole wagon(s)
|
||||
* (same route, same container type, partial wagon on both sides). Only 20ft lines ever
|
||||
|
||||
@@ -121,6 +121,15 @@ export class RebookCancelledWagonsDto {
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RebookContainerLineDto)
|
||||
containers?: RebookContainerLineDto[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Required when the credit carries an odd 20ft count: the odd-20ft booking ' +
|
||||
'GL picked to share the rebooked wagon (see the rebook-partners endpoint).',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
partnerBookingId?: string;
|
||||
}
|
||||
|
||||
export class FilterWagonCancellationsDto {
|
||||
|
||||
@@ -359,6 +359,12 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'customs_clearing_agent', type: 'varchar', length: 200, nullable: true })
|
||||
customsClearingAgent?: string | null;
|
||||
|
||||
@Column({ name: 'customs_clearing_agent_email', type: 'varchar', length: 200, nullable: true })
|
||||
customsClearingAgentEmail?: string | null;
|
||||
|
||||
@Column({ name: 'customs_clearing_agent_phone', type: 'varchar', length: 50, nullable: true })
|
||||
customsClearingAgentPhone?: string | null;
|
||||
|
||||
@Column({ name: 'equipment_return', type: 'varchar', length: 20 })
|
||||
equipmentReturn!: string;
|
||||
|
||||
@@ -411,6 +417,23 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'bulk_total_weight_tons', type: 'numeric', precision: 12, scale: 3, nullable: true })
|
||||
bulkTotalWeightTons?: number | null;
|
||||
|
||||
/**
|
||||
* NUMBER_OF_WAGONS bulk only: the wagon count the customer asked for at
|
||||
* booking. Allocation and PER_WAGON pricing use this count verbatim, and the
|
||||
* cargo weight spreads evenly across it (weight ÷ count per wagon — validated
|
||||
* against wagon capacity at creation). Null for every other cargo unit.
|
||||
*/
|
||||
@Column({ name: 'bulk_requested_wagons', type: 'int', nullable: true })
|
||||
bulkRequestedWagons?: number | null;
|
||||
|
||||
/**
|
||||
* NUMBER_OF_WAGONS bulk only: optional informational item count entered with
|
||||
* the weight. Never prices or sizes anything (unlike PER_ITEM, where the
|
||||
* count lives in cargoTotalWeightVgm).
|
||||
*/
|
||||
@Column({ name: 'bulk_item_count', type: 'int', nullable: true })
|
||||
bulkItemCount?: number | null;
|
||||
|
||||
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
|
||||
isHazardous!: boolean;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@tria-plc/api-common/modules/auth/decorators/current-user.decorator';
|
||||
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
import { FreightJwtGuard } from '../../common/freight-jwt.guard';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { ChatSync } from '../../common/booking-guards';
|
||||
@@ -18,7 +18,7 @@ export class ChatController {
|
||||
) {}
|
||||
|
||||
@Get('sso')
|
||||
@UseGuards(JwtGuard)
|
||||
@UseGuards(FreightJwtGuard)
|
||||
@ApiOperation({ summary: 'One-click sign-in link into EDR internal chat' })
|
||||
getSso(@CurrentUser() user: TCurrentUser) {
|
||||
return this.sso.getSsoUrl(user);
|
||||
|
||||
@@ -32,7 +32,9 @@ import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
||||
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
||||
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
|
||||
import type { ETradeBusinessOption } from "@edr/types";
|
||||
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
|
||||
import { AttachEtradeBusinessDto } from "./dto/attach-etrade-business.dto";
|
||||
import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
|
||||
import {
|
||||
CompanyIdentityStateDto,
|
||||
@@ -260,11 +262,42 @@ export class CompaniesController {
|
||||
): Promise<ResponseCompanyProfileDto[]> {
|
||||
const profiles = await this.companiesService.addCompanyProfilesForUser(
|
||||
user.id,
|
||||
dto.types,
|
||||
dto.profiles,
|
||||
);
|
||||
return profiles.map((p) => new ResponseCompanyProfileDto(p));
|
||||
}
|
||||
|
||||
@Get("etrade-businesses")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"The eTrade business licences under this company's TIN, for attaching to its operational profiles",
|
||||
})
|
||||
async listEtradeBusinesses(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<ETradeBusinessOption[]> {
|
||||
return this.companiesService.listEtradeBusinessesForUser(user.id);
|
||||
}
|
||||
|
||||
@Patch("company-profiles/:profileId/etrade-business")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Attach one of the TIN's eTrade businesses to an operational profile (re-attaching refreshes the stored snapshot)",
|
||||
})
|
||||
async attachEtradeBusiness(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("profileId") profileId: string,
|
||||
@Body() dto: AttachEtradeBusinessDto,
|
||||
): Promise<ResponseCompanyProfileDto> {
|
||||
const profile = await this.companiesService.attachEtradeBusinessToProfile(
|
||||
user.id,
|
||||
profileId,
|
||||
dto.licenceNumber,
|
||||
);
|
||||
return new ResponseCompanyProfileDto(profile);
|
||||
}
|
||||
|
||||
@Post("onboarding/start")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
@@ -321,6 +354,7 @@ export class CompaniesController {
|
||||
user.id,
|
||||
dto.type,
|
||||
dto.businessLicense,
|
||||
dto.licenceNumber,
|
||||
);
|
||||
return new ResponseCompanyProfileDto(profile);
|
||||
}
|
||||
|
||||
@@ -162,7 +162,16 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
{} as never,
|
||||
deps.filesService as never,
|
||||
deps.fileUploadSettings as never,
|
||||
{} as never,
|
||||
// Only the business-licence lookup is exercised here: adding a role now
|
||||
// resolves which eTrade business it operates as.
|
||||
{
|
||||
findBusinessOption: async (_tin: string, licenceNumber: string) => ({
|
||||
licenceNumber,
|
||||
tradeName: "Test Trade Name",
|
||||
activity: "Freight Forwarders",
|
||||
renewedTo: "7/7/2026",
|
||||
}),
|
||||
} as never,
|
||||
deps.companyNotifier as never,
|
||||
{} as never,
|
||||
deps.verifayda as never,
|
||||
@@ -502,7 +511,9 @@ describe("the owner is checked against the eTrade licence", () => {
|
||||
|
||||
describe("the freight-forwarder gate", () => {
|
||||
const addForwarder = (service: CompaniesService) =>
|
||||
service.addCompanyProfilesForUser("user-1", [ProfileType.freightForwarder]);
|
||||
service.addCompanyProfilesForUser("user-1", [
|
||||
{ type: ProfileType.freightForwarder, licenceNumber: "LIC-1" },
|
||||
]);
|
||||
|
||||
it("blocks the role while the representative is unverified", async () => {
|
||||
const { service } = makeService({ attributes: { poaDeclared: "yes" } });
|
||||
|
||||
@@ -133,7 +133,16 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
{} as never,
|
||||
deps.filesService as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
// Only the business-licence lookup is exercised here: adding a role now
|
||||
// resolves which eTrade business it operates as.
|
||||
{
|
||||
findBusinessOption: async (_tin: string, licenceNumber: string) => ({
|
||||
licenceNumber,
|
||||
tradeName: "Test Trade Name",
|
||||
activity: "Freight Forwarders",
|
||||
renewedTo: "7/7/2026",
|
||||
}),
|
||||
} as never,
|
||||
deps.companyNotifier as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
@@ -200,6 +209,8 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
undefined,
|
||||
"LIC-1",
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
@@ -263,6 +274,8 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
undefined,
|
||||
"LIC-1",
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
@@ -277,6 +290,8 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
undefined,
|
||||
"LIC-1",
|
||||
),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { BadRequestException, NotFoundException } from "@nestjs/common";
|
||||
import { CompaniesService } from "./companies.service";
|
||||
import { ProfileType } from "./entities/company-profile.entity";
|
||||
import { COOPERATIVE_KEY } from "./entities/company.entity";
|
||||
|
||||
/**
|
||||
* A TIN holds many business licences; each operational profile names the one it
|
||||
* trades as. What matters here is that the stored business is always eTrade's
|
||||
* own record, looked up under the company's own TIN — never the client's word
|
||||
* for it — and that the requirement lifts for a company eTrade knows nothing
|
||||
* about.
|
||||
*/
|
||||
const BUSINESSES = [
|
||||
{
|
||||
licenceNumber: "MT/AA/14/670/128936/2007",
|
||||
tradeName: "Pave Freight Forwarding",
|
||||
activity: "Freight Forwarders",
|
||||
renewedTo: "7/7/2026",
|
||||
},
|
||||
{
|
||||
licenceNumber: "MT/AA/14/670/11551235/2017",
|
||||
tradeName: "Pave Minerals Export",
|
||||
activity: "Export trade in minerals",
|
||||
renewedTo: "7/7/2026",
|
||||
},
|
||||
];
|
||||
|
||||
function makeService(attributes: Record<string, unknown> = {}) {
|
||||
const company = {
|
||||
id: "company-1",
|
||||
tin: "0045014036",
|
||||
type: "customer",
|
||||
attributes,
|
||||
companyProfiles: [{ id: "profile-1", type: ProfileType.exporter }],
|
||||
};
|
||||
|
||||
const created: Record<string, unknown>[] = [];
|
||||
const companyProfilesRepo = {
|
||||
findByCompanyId: jest.fn(async () => created),
|
||||
findByType: jest.fn(async () => null),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => {
|
||||
created.push({ id: `profile-${created.length + 2}`, ...row });
|
||||
return created[created.length - 1];
|
||||
}),
|
||||
update: jest.fn(async (id: string, data: Record<string, unknown>) => ({
|
||||
id,
|
||||
...data,
|
||||
})),
|
||||
};
|
||||
|
||||
const etradeService = {
|
||||
listBusinessOptions: jest.fn(async () => BUSINESSES),
|
||||
findBusinessOption: jest.fn(async (_tin: string, licenceNumber: string) => {
|
||||
const match = BUSINESSES.find((b) => b.licenceNumber === licenceNumber);
|
||||
if (!match) throw new BadRequestException("no such licence");
|
||||
return match;
|
||||
}),
|
||||
};
|
||||
|
||||
const service = new CompaniesService(
|
||||
{} as never,
|
||||
companyProfilesRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ findByUserId: jest.fn(async () => ({ id: "ext-1", companyId: "company-1" })) } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
etradeService as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(service, "getCompanyInfoByUserId")
|
||||
.mockImplementation(async () => ({ profile: {}, company }) as never);
|
||||
// Private, but every add path goes through it; stubbing it keeps this spec on
|
||||
// the business-attachment logic instead of the whole company lookup graph.
|
||||
(service as unknown as Record<string, unknown>).findCompanyById = async () =>
|
||||
company;
|
||||
|
||||
return { service, companyProfilesRepo, etradeService };
|
||||
}
|
||||
|
||||
describe("attaching an eTrade business to a company profile", () => {
|
||||
it("stores eTrade's own record for the chosen licence, not the client's", async () => {
|
||||
const { service, companyProfilesRepo } = makeService();
|
||||
const updated = await service.attachEtradeBusinessToProfile(
|
||||
"user-1",
|
||||
"profile-1",
|
||||
"MT/AA/14/670/128936/2007",
|
||||
);
|
||||
expect(companyProfilesRepo.update).toHaveBeenCalledWith("profile-1", {
|
||||
etradeBusiness: BUSINESSES[0],
|
||||
});
|
||||
expect(updated.etradeBusiness).toEqual(BUSINESSES[0]);
|
||||
});
|
||||
|
||||
it("refuses a licence eTrade does not list under this TIN", async () => {
|
||||
const { service } = makeService();
|
||||
await expect(
|
||||
service.attachEtradeBusinessToProfile("user-1", "profile-1", "SOMEONE/ELSES/LICENCE"),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("refuses a profile belonging to another company", async () => {
|
||||
const { service } = makeService();
|
||||
await expect(
|
||||
service.attachEtradeBusinessToProfile("user-1", "not-mine", BUSINESSES[0].licenceNumber),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it("the same business may back more than one profile", async () => {
|
||||
const { service, etradeService } = makeService();
|
||||
await service.addCompanyProfilesForUser("user-1", [
|
||||
{ type: ProfileType.exporter, licenceNumber: BUSINESSES[0].licenceNumber },
|
||||
{ type: ProfileType.importer, licenceNumber: BUSINESSES[0].licenceNumber },
|
||||
]);
|
||||
expect(etradeService.findBusinessOption).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("choosing a business is required when the company has one to choose", () => {
|
||||
it("rejects a role added without a licence", async () => {
|
||||
const { service } = makeService();
|
||||
await expect(
|
||||
service.addCompanyProfilesForUser("user-1", [{ type: ProfileType.exporter }]),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("lifts the requirement for a co-operative, which has no eTrade record", async () => {
|
||||
const { service, companyProfilesRepo, etradeService } = makeService({
|
||||
[COOPERATIVE_KEY]: true,
|
||||
});
|
||||
await service.addCompanyProfilesForUser("user-1", [
|
||||
{ type: ProfileType.exporter },
|
||||
]);
|
||||
expect(etradeService.findBusinessOption).not.toHaveBeenCalled();
|
||||
expect(companyProfilesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ etradeBusiness: null }),
|
||||
);
|
||||
});
|
||||
|
||||
it("offers a co-operative no businesses to pick from", async () => {
|
||||
const { service } = makeService({ [COOPERATIVE_KEY]: true });
|
||||
await expect(service.listEtradeBusinessesForUser("user-1")).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -84,6 +84,7 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
createdTo,
|
||||
onboardingCompleted,
|
||||
hasPendingChangeRequest,
|
||||
profileType,
|
||||
sortBy = 'review',
|
||||
sortOrder = 'DESC',
|
||||
} = query;
|
||||
@@ -138,20 +139,46 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
|
||||
if (search) {
|
||||
const term = `%${search.trim()}%`;
|
||||
// Staff search by whatever is in front of them: the company name, the
|
||||
// TIN/email, a profile reference off a document — and, since a TIN holds
|
||||
// many licences, the trade name or licence number of the specific
|
||||
// business a role operates as. All the per-profile terms share one EXISTS
|
||||
// so a match on any of them qualifies the company once.
|
||||
qb.andWhere(
|
||||
`(company.name ILIKE :term
|
||||
OR company.tin ILIKE :term
|
||||
OR company.email ILIKE :term
|
||||
OR company.licence_number ILIKE :term
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM freight.company_profiles cp
|
||||
WHERE cp.company_id = company.id
|
||||
AND cp.reference ILIKE :term
|
||||
AND cp.deleted_at IS NULL
|
||||
AND (
|
||||
cp.reference ILIKE :term
|
||||
OR cp.etrade_business->>'tradeName' ILIKE :term
|
||||
OR cp.etrade_business->>'licenceNumber' ILIKE :term
|
||||
)
|
||||
))`,
|
||||
{ term },
|
||||
);
|
||||
}
|
||||
|
||||
// Companies holding a given operational role. EXISTS rather than a filter
|
||||
// on the joined `companyProfiles` alias: constraining the join would drop
|
||||
// the company's OTHER profiles from the loaded entity, so the list would
|
||||
// render an exporter-and-importer as importer-only.
|
||||
if (profileType) {
|
||||
qb.andWhere(
|
||||
`EXISTS (
|
||||
SELECT 1 FROM freight.company_profiles cp_type
|
||||
WHERE cp_type.company_id = company.id
|
||||
AND cp_type.deleted_at IS NULL
|
||||
AND cp_type.type = :profileType
|
||||
)`,
|
||||
{ profileType },
|
||||
);
|
||||
}
|
||||
|
||||
// sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate.
|
||||
if (sortBy === 'review') {
|
||||
// Queue ordering: actionable tiers first, newest first within each. The
|
||||
|
||||
@@ -47,7 +47,7 @@ import { ETradeService } from "./services/etrade.service";
|
||||
import { CompanyNotifierService } from "./company-notifier.service";
|
||||
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
||||
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
|
||||
import type { CompanyRegistrationData } from "@edr/types";
|
||||
import type { CompanyRegistrationData, ETradeBusinessOption } from "@edr/types";
|
||||
import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
||||
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
||||
@@ -343,6 +343,11 @@ export class CompaniesService {
|
||||
companyId: company.id,
|
||||
type: input.type,
|
||||
businessLicense: input.businessLicense ?? null,
|
||||
etradeBusiness: await this.resolveProfileBusiness(
|
||||
company,
|
||||
input.licenceNumber,
|
||||
input.type,
|
||||
),
|
||||
status: ProfileStatus.Pending,
|
||||
});
|
||||
}
|
||||
@@ -2149,8 +2154,9 @@ export class CompaniesService {
|
||||
*/
|
||||
async addCompanyProfilesForUser(
|
||||
userId: string,
|
||||
types: ProfileType[],
|
||||
inputs: Array<{ type: ProfileType; licenceNumber?: string }>,
|
||||
): Promise<CompanyProfile[]> {
|
||||
const types = inputs.map((i) => i.type);
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
if (!profile)
|
||||
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||
@@ -2185,11 +2191,21 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
|
||||
// Which eTrade business this role operates as. Resolved (and rejected if
|
||||
// absent) BEFORE the row is created, so a role never lands unattached on
|
||||
// a company that has licences to pick from.
|
||||
const etradeBusiness = await this.resolveProfileBusiness(
|
||||
company,
|
||||
inputs.find((i) => i.type === type)?.licenceNumber,
|
||||
type,
|
||||
);
|
||||
|
||||
// Self-service role adds start Pending and carry no reference — a reference
|
||||
// is minted only when a backoffice reviewer approves the role.
|
||||
await this.companyProfilesRepo.create({
|
||||
companyId,
|
||||
type,
|
||||
etradeBusiness,
|
||||
status: ProfileStatus.Pending,
|
||||
});
|
||||
}
|
||||
@@ -2207,6 +2223,7 @@ export class CompaniesService {
|
||||
userId: string,
|
||||
type: ProfileType,
|
||||
businessLicense?: string,
|
||||
licenceNumber?: string,
|
||||
): Promise<CompanyProfile> {
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
if (!profile)
|
||||
@@ -2232,12 +2249,18 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
if (!created) {
|
||||
const etradeBusiness = await this.resolveProfileBusiness(
|
||||
company,
|
||||
licenceNumber,
|
||||
type,
|
||||
);
|
||||
// New self-service roles start Pending (awaiting backoffice approval) and
|
||||
// carry no reference until approved.
|
||||
created = await this.companyProfilesRepo.create({
|
||||
companyId,
|
||||
type,
|
||||
businessLicense: businessLicense ?? null,
|
||||
etradeBusiness,
|
||||
status: ProfileStatus.Pending,
|
||||
});
|
||||
}
|
||||
@@ -2320,6 +2343,7 @@ export class CompaniesService {
|
||||
type: p.type,
|
||||
reference: p.reference ?? "",
|
||||
uploaded: records.some((r) => r.code === LICENSE_CODE),
|
||||
etradeBusiness: p.etradeBusiness ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -2331,6 +2355,19 @@ export class CompaniesService {
|
||||
? []
|
||||
: licenseProfiles.filter((p) => !p.uploaded);
|
||||
|
||||
// Which eTrade business each role operates as. Enforced here rather than at
|
||||
// role creation because the wizard picks roles on its FIRST step, before a
|
||||
// TIN has been entered — there is nothing to pick from yet. The customer
|
||||
// attaches one on the documents step, alongside that role's licence file,
|
||||
// and onboarding cannot be submitted until every role has one.
|
||||
//
|
||||
// Lifted for a company with no eTrade record at all: a co-operative or a
|
||||
// foreign investor has no licence list, so the requirement would be
|
||||
// unsatisfiable (see `usesManualRegistration`).
|
||||
const missingBusinesses = usesManualRegistration(company)
|
||||
? []
|
||||
: licenseProfiles.filter((p) => !p.etradeBusiness);
|
||||
|
||||
// 4. Power of Attorney. Whether there is one at all is the company's own
|
||||
// declaration — the question the wizard asks outright — and that answer is
|
||||
// what decides whose identity gets verified, so an unanswered one is itself
|
||||
@@ -2378,6 +2415,10 @@ export class CompaniesService {
|
||||
(p) =>
|
||||
`Upload a business license for your ${p.type.replace(/_/g, " ")} profile`,
|
||||
),
|
||||
...missingBusinesses.map(
|
||||
(p) =>
|
||||
`Choose which eTrade business your ${p.type.replace(/_/g, " ")} profile operates as`,
|
||||
),
|
||||
...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`),
|
||||
...(missingDelegation
|
||||
? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`]
|
||||
@@ -2416,6 +2457,8 @@ export class CompaniesService {
|
||||
requiredInfo.length +
|
||||
requiredDocCount +
|
||||
(cooperative ? 0 : licenseProfiles.length) +
|
||||
// One "which business?" item per role, on the same terms as the licences.
|
||||
(usesManualRegistration(company) ? 0 : licenseProfiles.length) +
|
||||
poaItemCount +
|
||||
// The declaration and the verification it selects.
|
||||
2;
|
||||
@@ -2424,6 +2467,7 @@ export class CompaniesService {
|
||||
(missingInfo.length +
|
||||
missingDocs.length +
|
||||
missingLicenses.length +
|
||||
missingBusinesses.length +
|
||||
missingPoaFields.length +
|
||||
(missingDelegation || flaggedDelegation ? 1 : 0) +
|
||||
missingIdentityCount);
|
||||
@@ -3747,6 +3791,86 @@ export class CompaniesService {
|
||||
return match?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the eTrade business a new/updated profile is being attached to.
|
||||
*
|
||||
* The client sends a licence number; what gets stored is eTrade's own record
|
||||
* of it, looked up under THIS company's TIN. That is the whole check — a
|
||||
* licence belonging to someone else's TIN simply is not in the list, so a
|
||||
* client cannot attach a profile to a business the company does not hold.
|
||||
*
|
||||
* Returns null (rather than throwing) for a company that registered without
|
||||
* eTrade: a co-operative union or farm holds no business licence, and a
|
||||
* foreign investor's licence is the Investment Commission's, not the trade
|
||||
* registry's. There is no list for them to pick from, so the role is theirs
|
||||
* to hold unattached — the reviewer checks their uploaded documents instead.
|
||||
*/
|
||||
private async resolveProfileBusiness(
|
||||
company: Company,
|
||||
licenceNumber: string | undefined,
|
||||
type: ProfileType,
|
||||
): Promise<ETradeBusinessOption | null> {
|
||||
if (usesManualRegistration(company)) return null;
|
||||
if (!licenceNumber) {
|
||||
throw new BadRequestException(
|
||||
`Choose which of your eTrade business licences the ${type.replace(/_/g, " ")} profile operates as.`,
|
||||
);
|
||||
}
|
||||
return this.etradeService.findBusinessOption(company.tin, licenceNumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* The eTrade business licences the current user's company can attach to its
|
||||
* operational profiles. Empty for a company that registered without eTrade.
|
||||
*/
|
||||
async listEtradeBusinessesForUser(
|
||||
userId: string,
|
||||
): Promise<ETradeBusinessOption[]> {
|
||||
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||
if (usesManualRegistration(company)) return [];
|
||||
return this.etradeService.listBusinessOptions(company.tin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach (or re-attach) one of the TIN's eTrade businesses to a profile.
|
||||
*
|
||||
* Separate from role creation because the onboarding wizard picks roles
|
||||
* before the TIN is known — the business is chosen later, on the step that
|
||||
* already collects each role's licence document. Re-attaching also refreshes
|
||||
* the stored snapshot, which is how a renewed licence's new expiry lands.
|
||||
*/
|
||||
async attachEtradeBusinessToProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
licenceNumber: string,
|
||||
): Promise<CompanyProfile> {
|
||||
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||
const profile = (company.companyProfiles ?? []).find(
|
||||
(p) => p.id === profileId,
|
||||
);
|
||||
if (!profile) {
|
||||
throw new NotFoundException(
|
||||
`Company profile ${profileId} not found for this company`,
|
||||
);
|
||||
}
|
||||
if (usesManualRegistration(company)) {
|
||||
throw new BadRequestException(
|
||||
"This company is not registered with eTrade, so it has no business licences to attach.",
|
||||
);
|
||||
}
|
||||
const business = await this.etradeService.findBusinessOption(
|
||||
company.tin,
|
||||
licenceNumber,
|
||||
);
|
||||
const updated = await this.companyProfilesRepo.update(profile.id, {
|
||||
etradeBusiness: business,
|
||||
});
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Resolve a TIN's live eTrade registration data. Throws when eTrade has no matching business licence. */
|
||||
private async resolveEtradeRegistration(
|
||||
tin: string,
|
||||
|
||||
@@ -1,9 +1,37 @@
|
||||
import { IsArray, IsEnum, ArrayMinSize } from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { ProfileType } from "../entities/company-profile.entity";
|
||||
|
||||
export class AddCompanyProfileInputDto {
|
||||
@IsEnum(ProfileType)
|
||||
type!: ProfileType;
|
||||
|
||||
/**
|
||||
* Which of the TIN's eTrade business licences this role operates as.
|
||||
*
|
||||
* Optional at the DTO layer, required by the service for any company that
|
||||
* HAS an eTrade record — a co-operative or investor-licence company has none
|
||||
* to pick from, and rejecting them here would be wrong. See
|
||||
* `CompaniesService.resolveProfileBusiness`.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
licenceNumber?: string;
|
||||
}
|
||||
|
||||
export class AddCompanyProfilesDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsEnum(ProfileType, { each: true })
|
||||
types!: ProfileType[];
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AddCompanyProfileInputDto)
|
||||
profiles!: AddCompanyProfileInputDto[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsNotEmpty, IsString, MaxLength } from "class-validator";
|
||||
|
||||
export class AttachEtradeBusinessDto {
|
||||
/**
|
||||
* The eTrade licence number of the business this profile operates as. Checked
|
||||
* against the licences eTrade lists under the company's own TIN, so an
|
||||
* unknown or someone else's licence is rejected rather than stored.
|
||||
*/
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
licenceNumber!: string;
|
||||
}
|
||||
@@ -9,4 +9,14 @@ export class CreateCompanyProfileDto {
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
businessLicense?: string;
|
||||
|
||||
/**
|
||||
* Which of the TIN's eTrade business licences this role operates as. Required
|
||||
* by the service for any company that has an eTrade record; see
|
||||
* `AddCompanyProfileInputDto.licenceNumber`.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
licenceNumber?: string;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,16 @@ export class CompanyProfileInputDto {
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
businessLicense?: string;
|
||||
|
||||
/**
|
||||
* Which of the TIN's eTrade business licences this role operates as. Required
|
||||
* by the service for any company that has an eTrade record; see
|
||||
* `CompaniesService.resolveProfileBusiness`.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
licenceNumber?: string;
|
||||
}
|
||||
|
||||
export class CreateCompanyWithProfileDto {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
CompanyStatus,
|
||||
CompanyType,
|
||||
} from "../entities/company.entity";
|
||||
import { ProfileType } from "../entities/company-profile.entity";
|
||||
|
||||
export class ListCompaniesQueryDto {
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@@ -56,6 +57,16 @@ export class ListCompaniesQueryDto {
|
||||
@IsIn(Object.values(CompanyNationality))
|
||||
nationality?: CompanyNationality;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ProfileType,
|
||||
description:
|
||||
"Only companies holding this operational role. A company may hold " +
|
||||
"several; its other roles are still returned on the row.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(Object.values(ProfileType))
|
||||
profileType?: ProfileType;
|
||||
|
||||
@ApiPropertyOptional({ description: "Registered on or after this instant (ISO)." })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* truth the wizard uses to auto-finish.
|
||||
*/
|
||||
|
||||
import type { ETradeBusinessOption } from "@edr/types";
|
||||
import {
|
||||
CompanyIdentityStateDto,
|
||||
PoaDeclaration,
|
||||
@@ -38,6 +39,11 @@ export interface OnboardingLicenseProfile {
|
||||
reference: string;
|
||||
/** True when at least one business-license file is stored on the profile. */
|
||||
uploaded: boolean;
|
||||
/**
|
||||
* The eTrade business this role operates as, once the customer has attached
|
||||
* one. Null while outstanding — the wizard renders the picker off this.
|
||||
*/
|
||||
etradeBusiness: ETradeBusinessOption | null;
|
||||
}
|
||||
|
||||
export interface OnboardingPoaState {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
hasInvestorLicence,
|
||||
isCooperative,
|
||||
} from '../entities/company.entity';
|
||||
import type { ETradeBusinessOption } from '@edr/types';
|
||||
import {
|
||||
CompanyProfile,
|
||||
ProfileLicenseFileView,
|
||||
@@ -31,6 +32,12 @@ export class ResponseCompanyProfileDto {
|
||||
*/
|
||||
licenseFiles: ProfileLicenseFileView[];
|
||||
attributes?: Record<string, any> | null;
|
||||
/**
|
||||
* The eTrade business licence this role operates as, or null when nothing is
|
||||
* attached yet (or the company registered without eTrade). Snapshot — see
|
||||
* `CompanyProfile.etradeBusiness`.
|
||||
*/
|
||||
etradeBusiness?: ETradeBusinessOption | null;
|
||||
/** Reviewer note when the role is rejected (drives the reapply prompt). */
|
||||
reviewNote?: string | null;
|
||||
createdAt: Date;
|
||||
@@ -45,6 +52,7 @@ export class ResponseCompanyProfileDto {
|
||||
this.businessLicense = profile.businessLicense;
|
||||
this.licenseFiles = [];
|
||||
this.attributes = profile.attributes;
|
||||
this.etradeBusiness = profile.etradeBusiness ?? null;
|
||||
this.reviewNote = profile.reviewNote ?? null;
|
||||
this.createdAt = profile.createdAt;
|
||||
this.updatedAt = profile.updatedAt;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import type { ETradeBusinessOption } from "@edr/types";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
import { Company } from "./company.entity";
|
||||
|
||||
@@ -126,6 +127,23 @@ export class CompanyProfile extends BaseEntity {
|
||||
@Column({ name: "business_license_files", type: "jsonb", nullable: true })
|
||||
businessLicenseFiles?: BusinessLicenseFile[] | null;
|
||||
|
||||
/**
|
||||
* Which of the TIN's eTrade business licences this profile operates as.
|
||||
*
|
||||
* A TIN holds many licences split by activity, so "exporter" and "freight
|
||||
* forwarder" are usually two different businesses under one company. Stored
|
||||
* as a snapshot rather than a bare licence number so the trade name and
|
||||
* activity render without an eTrade call — that API is slow and regularly
|
||||
* down, and this is display data, not a source of truth. Re-attaching
|
||||
* refreshes it.
|
||||
*
|
||||
* NULL when nothing is attached yet, or when the company registered without
|
||||
* eTrade at all (co-operative / investor licence — see
|
||||
* {@link usesManualRegistration}). One business may back several profiles.
|
||||
*/
|
||||
@Column({ name: "etrade_business", type: "jsonb", nullable: true })
|
||||
etradeBusiness?: ETradeBusinessOption | null;
|
||||
|
||||
@Column({ name: "attributes", type: "jsonb", nullable: true })
|
||||
attributes?: Record<string, any> | null;
|
||||
|
||||
|
||||
@@ -78,6 +78,27 @@ describe('ETradeService business selection', () => {
|
||||
expect(data.businesses?.[0].activity).toBe('Export trade in minerals');
|
||||
});
|
||||
|
||||
it("takes the selected licence's trade name as the company name", () => {
|
||||
const { service } = build();
|
||||
const data = service.extractRegistrationData(
|
||||
{
|
||||
LicenceNumber: 'MT/AA/14/670/128936/2007',
|
||||
TradeName: 'Pave Freight Forwarding',
|
||||
} as ETradeBusinessInfo,
|
||||
companyInfo(),
|
||||
);
|
||||
expect(data.companyName).toBe('Pave Freight Forwarding');
|
||||
});
|
||||
|
||||
it('falls back to the registered name when the licence has no trade name', () => {
|
||||
const { service } = build();
|
||||
const data = service.extractRegistrationData(
|
||||
{ LicenceNumber: 'x', TradeName: ' ' } as ETradeBusinessInfo,
|
||||
companyInfo(),
|
||||
);
|
||||
expect(data.companyName).toBe('PAVE LOGISTICS AND TRADING P L C');
|
||||
});
|
||||
|
||||
it('lists every licence for the picker, code prefixes stripped', () => {
|
||||
const { service } = build();
|
||||
const data = service.extractRegistrationData(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { firstValueFrom } from "rxjs";
|
||||
import {
|
||||
ETradeCompanyInfo,
|
||||
ETradeBusinessInfo,
|
||||
ETradeBusinessOption,
|
||||
CompanyRegistrationData,
|
||||
normalizeRegion,
|
||||
} from "@edr/types";
|
||||
@@ -102,10 +103,16 @@ export class ETradeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* `companyInfo` carries the registered organization name (`BusinessName`);
|
||||
* `businessInfo` only carries the licence's `TradeName`. Pass both so the
|
||||
* company name resolves to the legal entity rather than the trade name — and
|
||||
* never to `ManagerNameEng`, which is the manager's personal name.
|
||||
* `businessInfo` carries the selected licence's `TradeName`; `companyInfo`
|
||||
* carries the registered organization name (`BusinessName`). The company name
|
||||
* resolves to the trade name of the licence the customer picked — a TIN
|
||||
* routinely trades under a name that is not its registered one, and the
|
||||
* business they selected is the one they operate as here. `BusinessName` is
|
||||
* the fallback, because eTrade leaves `TradeName` blank on plenty of licences.
|
||||
* Never `ManagerNameEng`, which is the manager's personal name.
|
||||
*
|
||||
* Callers that need the legal entity (tax filings, EIMS seller details) must
|
||||
* read `companyInfo.BusinessName` themselves — it is not this field.
|
||||
*/
|
||||
extractRegistrationData(
|
||||
businessInfo: ETradeBusinessInfo,
|
||||
@@ -115,7 +122,7 @@ export class ETradeService {
|
||||
|
||||
return {
|
||||
companyName:
|
||||
companyInfo?.BusinessName?.trim() || businessInfo.TradeName?.trim() || "",
|
||||
businessInfo.TradeName?.trim() || companyInfo?.BusinessName?.trim() || "",
|
||||
licenceNumber: businessInfo.LicenceNumber,
|
||||
statusDescription: businessInfo.StatusDescription,
|
||||
dateRegistered: businessInfo.DateRegistered,
|
||||
@@ -137,17 +144,57 @@ export class ETradeService {
|
||||
regularPhone: businessInfo.AddressInfo?.RegularPhone || "",
|
||||
managerName: primaryManager?.ManagerNameEng || "",
|
||||
managerPhone: primaryManager?.RegularPhone || "",
|
||||
businesses: (companyInfo?.Businesses ?? []).map((b) => ({
|
||||
licenceNumber: b.LicenceNumber,
|
||||
tradeName: b.TradesName?.trim() || "",
|
||||
activity: (b.SubGroups ?? [])
|
||||
// Some descriptions repeat the code inline ("(65611)Import trade …").
|
||||
// eTrade also puts null entries in this array, so every hop is optional.
|
||||
.map((g) => g?.Description?.replace(/^\(\d+\)\s*/, "").trim())
|
||||
.filter(Boolean)
|
||||
.join(", "),
|
||||
renewedTo: b.RenewedTo || "",
|
||||
})),
|
||||
businesses: (companyInfo?.Businesses ?? []).map(toBusinessOption),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every business licence held under a TIN, as the customer picks them.
|
||||
*
|
||||
* Split out from {@link extractRegistrationData} because attaching a business
|
||||
* to a company profile needs the list alone — no licence detail fetch, so one
|
||||
* eTrade call instead of two.
|
||||
*/
|
||||
async listBusinessOptions(tin: string): Promise<ETradeBusinessOption[]> {
|
||||
const companyInfo = await this.getCompanyInfoByTin(tin);
|
||||
return (companyInfo.Businesses ?? []).map(toBusinessOption);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one of the TIN's licences, or throw if eTrade does not list it.
|
||||
*
|
||||
* This is the trust boundary for a client-supplied licence number: a profile
|
||||
* may only ever be attached to a business eTrade actually holds under that
|
||||
* TIN, so the snapshot that gets stored is eTrade's own data, never the
|
||||
* client's.
|
||||
*/
|
||||
async findBusinessOption(
|
||||
tin: string,
|
||||
licenceNumber: string,
|
||||
): Promise<ETradeBusinessOption> {
|
||||
const options = await this.listBusinessOptions(tin);
|
||||
const match = options.find((b) => b.licenceNumber === licenceNumber);
|
||||
if (!match) {
|
||||
throw new BadRequestException(
|
||||
`eTrade lists no business licence "${licenceNumber}" under TIN ${tin}.`,
|
||||
);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
}
|
||||
|
||||
function toBusinessOption(
|
||||
b: ETradeCompanyInfo["Businesses"][number],
|
||||
): ETradeBusinessOption {
|
||||
return {
|
||||
licenceNumber: b.LicenceNumber,
|
||||
tradeName: b.TradesName?.trim() || "",
|
||||
activity: (b.SubGroups ?? [])
|
||||
// Some descriptions repeat the code inline ("(65611)Import trade …").
|
||||
// eTrade also puts null entries in this array, so every hop is optional.
|
||||
.map((g) => g?.Description?.replace(/^\(\d+\)\s*/, "").trim())
|
||||
.filter(Boolean)
|
||||
.join(", "),
|
||||
renewedTo: b.RenewedTo || "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,15 +34,27 @@ describe('bulkTemplateCode', () => {
|
||||
expect(bulkTemplateCode('STEEL', 'INTERCITY', null)).toBe('BULK_INTERCITY_STEEL');
|
||||
});
|
||||
|
||||
it('produces 5 distinct codes per cargo type', () => {
|
||||
it('gives the Ethiopian-customs-only variant its own suffix', () => {
|
||||
expect(bulkTemplateCode('STEEL', 'IMPORT', true, true)).toBe(
|
||||
'BULK_IMPORT_STEEL_ETHIOPIAN_CUSTOMS',
|
||||
);
|
||||
// The flag is meaningless without customs clearing.
|
||||
expect(bulkTemplateCode('STEEL', 'IMPORT', false, true)).toBe(
|
||||
'BULK_IMPORT_STEEL_NO_CUSTOMS',
|
||||
);
|
||||
});
|
||||
|
||||
it('produces 7 distinct codes per cargo type', () => {
|
||||
const codes = [
|
||||
bulkTemplateCode('STEEL', 'IMPORT', true),
|
||||
bulkTemplateCode('STEEL', 'IMPORT', true, true),
|
||||
bulkTemplateCode('STEEL', 'IMPORT', false),
|
||||
bulkTemplateCode('STEEL', 'EXPORT', true),
|
||||
bulkTemplateCode('STEEL', 'EXPORT', true, true),
|
||||
bulkTemplateCode('STEEL', 'EXPORT', false),
|
||||
bulkTemplateCode('STEEL', 'INTERCITY', null),
|
||||
];
|
||||
expect(new Set(codes).size).toBe(5);
|
||||
expect(new Set(codes).size).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,6 +124,33 @@ describe('ContractTemplatesService bulk create/resolve', () => {
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('creates the Ethiopian-customs-only variant alongside the full-customs one', async () => {
|
||||
const { service } = build();
|
||||
const created = await service.create({
|
||||
cargoTypeId: 'cargo-1',
|
||||
tradeDirection: 'IMPORT',
|
||||
withCustoms: true,
|
||||
ethiopianCustomsOnly: true,
|
||||
});
|
||||
expect(created.code).toBe('BULK_IMPORT_STEEL_ETHIOPIAN_CUSTOMS');
|
||||
expect(created.ethiopianCustomsOnly).toBe(true);
|
||||
expect(created.documentTitle).toBe(
|
||||
'Steel Transportation and Ethiopian Customs Clearance Services',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects Ethiopian-customs-only without customs clearing', async () => {
|
||||
const { service } = build();
|
||||
await expect(
|
||||
service.create({
|
||||
cargoTypeId: 'cargo-1',
|
||||
tradeDirection: 'IMPORT',
|
||||
withCustoms: false,
|
||||
ethiopianCustomsOnly: true,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('resolves a domestic bulk contract to the intercity template, ignoring its customs flag', async () => {
|
||||
const { repository, service } = build();
|
||||
await service.findActiveForContract('DOMESTIC', 'BULK', true, 'cargo-1');
|
||||
@@ -119,6 +158,7 @@ describe('ContractTemplatesService bulk create/resolve', () => {
|
||||
'cargo-1',
|
||||
'INTERCITY',
|
||||
null,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -129,6 +169,18 @@ describe('ContractTemplatesService bulk create/resolve', () => {
|
||||
'cargo-1',
|
||||
'IMPORT',
|
||||
false,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves an Ethiopian-customs-only contract to the Ethiopian variant', async () => {
|
||||
const { repository, service } = build();
|
||||
await service.findActiveForContract('IMPORT', 'BULK', true, 'cargo-1', true);
|
||||
expect(repository.findActiveBulkTemplate).toHaveBeenCalledWith(
|
||||
'cargo-1',
|
||||
'IMPORT',
|
||||
true,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,22 @@ describe('contractTemplateCodeFor', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves the Ethiopian variant only when customs clearing is enabled', () => {
|
||||
expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', true, true)).toBe(
|
||||
'IMPORT_CONTAINER_ETHIOPIAN_CUSTOMS',
|
||||
);
|
||||
expect(contractTemplateCodeFor('EXPORT', 'BULK', true, true)).toBe(
|
||||
'EXPORT_BULK_ETHIOPIAN_CUSTOMS',
|
||||
);
|
||||
// Without customs clearing the Ethiopian flag is meaningless.
|
||||
expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', false, true)).toBe(
|
||||
'IMPORT_CONTAINER_NO_CUSTOMS',
|
||||
);
|
||||
expect(contractTemplateCodeFor('DOMESTIC', 'CONTAINER', true, true)).toBe(
|
||||
'INTERCITY_CONTAINER',
|
||||
);
|
||||
});
|
||||
|
||||
it('never gives intercity a customs variant — it crosses no border', () => {
|
||||
for (const flag of [true, false, null, undefined]) {
|
||||
expect(contractTemplateCodeFor('DOMESTIC', 'BULK', flag)).toBe('INTERCITY_BULK');
|
||||
@@ -40,7 +56,11 @@ describe('contractTemplateCodeFor', () => {
|
||||
for (const d of directions) {
|
||||
for (const f of freights) {
|
||||
for (const c of [true, false]) {
|
||||
expect(CONTRACT_TEMPLATE_CODES).toContain(contractTemplateCodeFor(d, f, c));
|
||||
for (const e of [true, false, undefined]) {
|
||||
expect(CONTRACT_TEMPLATE_CODES).toContain(
|
||||
contractTemplateCodeFor(d, f, c, e),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,9 +68,9 @@ describe('contractTemplateCodeFor', () => {
|
||||
});
|
||||
|
||||
describe('CONTRACT_TEMPLATE_DEFAULTS', () => {
|
||||
it('seeds exactly the ten declared codes, once each', () => {
|
||||
it('seeds exactly the fourteen declared codes, once each', () => {
|
||||
const seeded = CONTRACT_TEMPLATE_DEFAULTS.map((t) => t.code).sort();
|
||||
expect(seeded).toHaveLength(10);
|
||||
expect(seeded).toHaveLength(14);
|
||||
expect(seeded).toEqual([...CONTRACT_TEMPLATE_CODES].sort());
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { IsNull, Repository } from "typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
||||
import {
|
||||
@@ -33,10 +33,22 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
|
||||
cargoTypeId: string,
|
||||
tradeDirection: BulkTemplateDirection,
|
||||
withCustoms: boolean | null,
|
||||
ethiopianCustomsOnly = false,
|
||||
): Promise<ContractTemplate | null> {
|
||||
return this.repository.findOne({
|
||||
where: { cargoTypeId, tradeDirection, withCustoms: withCustoms ?? IsNull() },
|
||||
});
|
||||
return this.repository
|
||||
.createQueryBuilder("t")
|
||||
.where("t.cargo_type_id = :cargoTypeId", { cargoTypeId })
|
||||
.andWhere("t.trade_direction = :tradeDirection", { tradeDirection })
|
||||
.andWhere(
|
||||
withCustoms === null
|
||||
? "t.with_customs IS NULL"
|
||||
: "t.with_customs = :withCustoms",
|
||||
withCustoms === null ? {} : { withCustoms },
|
||||
)
|
||||
.andWhere("COALESCE(t.ethiopian_customs_only, false) = :ethiopianCustomsOnly", {
|
||||
ethiopianCustomsOnly,
|
||||
})
|
||||
.getOne();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,6 +61,7 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
|
||||
cargoTypeId: string,
|
||||
tradeDirection: BulkTemplateDirection,
|
||||
withCustoms: boolean | null,
|
||||
ethiopianCustomsOnly = false,
|
||||
): Promise<ContractTemplate | null> {
|
||||
return this.repository
|
||||
.createQueryBuilder("t")
|
||||
@@ -60,6 +73,9 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
|
||||
: "t.with_customs = :withCustoms",
|
||||
withCustoms === null ? {} : { withCustoms },
|
||||
)
|
||||
.andWhere("COALESCE(t.ethiopian_customs_only, false) = :ethiopianCustomsOnly", {
|
||||
ethiopianCustomsOnly,
|
||||
})
|
||||
.andWhere(
|
||||
`(t.cargo_type_id = :cargoTypeId OR t.cargo_type_id = (
|
||||
SELECT c.parent_group_id FROM freight.cargo_types c
|
||||
|
||||
@@ -41,13 +41,17 @@ import {
|
||||
*/
|
||||
const PREVIEW_TEMPLATE_KEYS: Record<ContractTemplateCode, string> = {
|
||||
IMPORT_BULK_CUSTOMS: "IMP_BULK_USD_FORWARDING",
|
||||
IMPORT_BULK_ETHIOPIAN_CUSTOMS: "IMP_BULK_USD_FORWARDING",
|
||||
IMPORT_BULK_NO_CUSTOMS: "IMP_BULK_USD_TRANSPORT_ONLY",
|
||||
EXPORT_BULK_CUSTOMS: "EXP_BULK_USD_FORWARDING",
|
||||
EXPORT_BULK_ETHIOPIAN_CUSTOMS: "EXP_BULK_USD_FORWARDING",
|
||||
EXPORT_BULK_NO_CUSTOMS: "EXP_BULK_USD_TRANSPORT_ONLY",
|
||||
INTERCITY_BULK: "DOM_BULK_USD_TRANSPORT_ONLY",
|
||||
IMPORT_CONTAINER_CUSTOMS: "IMP_CON_USD_FORWARDING",
|
||||
IMPORT_CONTAINER_ETHIOPIAN_CUSTOMS: "IMP_CON_USD_FORWARDING",
|
||||
IMPORT_CONTAINER_NO_CUSTOMS: "IMP_CON_USD_TRANSPORT_ONLY",
|
||||
EXPORT_CONTAINER_CUSTOMS: "EXP_CON_USD_FORWARDING",
|
||||
EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS: "EXP_CON_USD_FORWARDING",
|
||||
EXPORT_CONTAINER_NO_CUSTOMS: "EXP_CON_USD_TRANSPORT_ONLY",
|
||||
INTERCITY_CONTAINER: "DOM_CON_USD_TRANSPORT_ONLY",
|
||||
};
|
||||
@@ -101,7 +105,7 @@ export class ContractTemplatesService {
|
||||
|
||||
const direction = dto.tradeDirection;
|
||||
const intercity = direction === "INTERCITY";
|
||||
if (intercity && dto.withCustoms !== undefined) {
|
||||
if (intercity && (dto.withCustoms !== undefined || dto.ethiopianCustomsOnly)) {
|
||||
throw new BadRequestException(
|
||||
"Intercity contracts are domestic and cross no border — they have no customs clearing variant",
|
||||
);
|
||||
@@ -112,12 +116,24 @@ export class ContractTemplatesService {
|
||||
);
|
||||
}
|
||||
const withCustoms = intercity ? null : Boolean(dto.withCustoms);
|
||||
const ethiopianOnly = Boolean(dto.ethiopianCustomsOnly) && !intercity;
|
||||
if (ethiopianOnly && !withCustoms) {
|
||||
throw new BadRequestException(
|
||||
"Ethiopian-customs-only is a customs clearing variant — it requires withCustoms to be true",
|
||||
);
|
||||
}
|
||||
|
||||
const label = this.comboLabel(cargoType.cargoTypeName, direction, withCustoms);
|
||||
const label = this.comboLabel(
|
||||
cargoType.cargoTypeName,
|
||||
direction,
|
||||
withCustoms,
|
||||
ethiopianOnly,
|
||||
);
|
||||
const existing = await this.repository.findByCargoCombo(
|
||||
dto.cargoTypeId,
|
||||
direction,
|
||||
withCustoms,
|
||||
ethiopianOnly,
|
||||
);
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
@@ -126,11 +142,13 @@ export class ContractTemplatesService {
|
||||
}
|
||||
|
||||
const template = new ContractTemplate();
|
||||
template.code = bulkTemplateCode(cargoType.code, direction, withCustoms);
|
||||
template.code = bulkTemplateCode(cargoType.code, direction, withCustoms, ethiopianOnly);
|
||||
template.name = dto.name ?? label;
|
||||
template.description = dto.description ?? null;
|
||||
template.documentTitle = withCustoms
|
||||
? `${cargoType.cargoTypeName} Transportation and Customs Clearance Services`
|
||||
? ethiopianOnly
|
||||
? `${cargoType.cargoTypeName} Transportation and Ethiopian Customs Clearance Services`
|
||||
: `${cargoType.cargoTypeName} Transportation and Customs Clearance Services`
|
||||
: `${cargoType.cargoTypeName} Transportation Services`;
|
||||
template.whereasClauses = [];
|
||||
template.articles = [];
|
||||
@@ -138,6 +156,7 @@ export class ContractTemplatesService {
|
||||
template.cargoTypeId = cargoType.id;
|
||||
template.tradeDirection = direction;
|
||||
template.withCustoms = withCustoms;
|
||||
template.ethiopianCustomsOnly = intercity ? null : ethiopianOnly;
|
||||
template.isSystem = false;
|
||||
try {
|
||||
return await this.repository.saveTemplate(template);
|
||||
@@ -157,18 +176,21 @@ export class ContractTemplatesService {
|
||||
cargoTypeName: string,
|
||||
direction: BulkTemplateDirection,
|
||||
withCustoms: boolean | null,
|
||||
ethiopianCustomsOnly = false,
|
||||
): string {
|
||||
const dir = direction.charAt(0) + direction.slice(1).toLowerCase();
|
||||
const customs =
|
||||
withCustoms === null
|
||||
? ""
|
||||
: withCustoms
|
||||
? ", with customs clearing"
|
||||
? ethiopianCustomsOnly
|
||||
? ", with Ethiopian customs clearing only"
|
||||
: ", with customs clearing"
|
||||
: ", without customs clearing";
|
||||
return `${cargoTypeName} Bulk Contract (${dir}${customs})`;
|
||||
}
|
||||
|
||||
/** Bulk templates only — the five seeded container templates are permanent. */
|
||||
/** Bulk templates only — the seeded container templates are permanent. */
|
||||
async remove(code: string): Promise<void> {
|
||||
const template = await this.getByCode(code);
|
||||
if (template.isSystem) {
|
||||
@@ -193,6 +215,7 @@ export class ContractTemplatesService {
|
||||
freightType?: string | null,
|
||||
customsClearingEnabled?: boolean | null,
|
||||
cargoTypeId?: string | null,
|
||||
ethiopianCustomsOnly?: boolean | null,
|
||||
): Promise<ContractTemplate | null> {
|
||||
const isBulk = (freightType ?? "").toUpperCase().includes("BULK");
|
||||
if (isBulk) {
|
||||
@@ -202,12 +225,16 @@ export class ContractTemplatesService {
|
||||
cargoTypeId,
|
||||
direction,
|
||||
direction === "INTERCITY" ? null : Boolean(customsClearingEnabled),
|
||||
direction === "INTERCITY"
|
||||
? false
|
||||
: Boolean(customsClearingEnabled && ethiopianCustomsOnly),
|
||||
);
|
||||
}
|
||||
const code = contractTemplateCodeFor(
|
||||
tradeDirection,
|
||||
freightType,
|
||||
customsClearingEnabled,
|
||||
ethiopianCustomsOnly,
|
||||
);
|
||||
const template = await this.repository.findByCode(code);
|
||||
return template?.isActive ? template : null;
|
||||
|
||||
@@ -43,6 +43,14 @@ export class CreateContractTemplateDto {
|
||||
@IsBoolean()
|
||||
withCustoms?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Restricts the with-customs variant to Ethiopian-side clearing only (Djibouti stays with the Client). Requires withCustoms=true; rejected for INTERCITY",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
ethiopianCustomsOnly?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: "Display name (derived from the cargo type when omitted)" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -4,9 +4,10 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
import { CargoType } from "../../rule-engine/entities/cargo-type.entity";
|
||||
|
||||
/**
|
||||
* The five seeded container templates (import/export split by customs
|
||||
* clearing; intercity is domestic, crosses no border, so it has a single
|
||||
* template). These are system rows: always present, never deletable.
|
||||
* The seeded container templates (import/export split by customs-clearing
|
||||
* option — full, Ethiopian-only, none; intercity is domestic, crosses no
|
||||
* border, so it has a single template). These are system rows: always
|
||||
* present, never deletable.
|
||||
*
|
||||
* Bulk templates are NOT seeded — staff create them per bulk cargo type
|
||||
* (`cargoTypeId`), trade direction (`tradeDirection`) and customs option
|
||||
@@ -20,18 +21,24 @@ import { CargoType } from "../../rule-engine/entities/cargo-type.entity";
|
||||
*
|
||||
* The `_CUSTOMS` variant is issued when the contract has customs clearing
|
||||
* enabled (the Service Provider clears in Djibouti/Ethiopia on the Client's
|
||||
* behalf); `_NO_CUSTOMS` is the transport-only paper, where the Client handles
|
||||
* behalf); `_ETHIOPIAN_CUSTOMS` when the service type is Ethiopian-customs-only
|
||||
* (the Service Provider clears the Ethiopian side only, Djibouti stays with the
|
||||
* Client); `_NO_CUSTOMS` is the transport-only paper, where the Client handles
|
||||
* its own declarations.
|
||||
*/
|
||||
export const CONTRACT_TEMPLATE_CODES = [
|
||||
"IMPORT_BULK_CUSTOMS",
|
||||
"IMPORT_BULK_ETHIOPIAN_CUSTOMS",
|
||||
"IMPORT_BULK_NO_CUSTOMS",
|
||||
"EXPORT_BULK_CUSTOMS",
|
||||
"EXPORT_BULK_ETHIOPIAN_CUSTOMS",
|
||||
"EXPORT_BULK_NO_CUSTOMS",
|
||||
"INTERCITY_BULK",
|
||||
"IMPORT_CONTAINER_CUSTOMS",
|
||||
"IMPORT_CONTAINER_ETHIOPIAN_CUSTOMS",
|
||||
"IMPORT_CONTAINER_NO_CUSTOMS",
|
||||
"EXPORT_CONTAINER_CUSTOMS",
|
||||
"EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS",
|
||||
"EXPORT_CONTAINER_NO_CUSTOMS",
|
||||
"INTERCITY_CONTAINER",
|
||||
] as const;
|
||||
@@ -66,6 +73,7 @@ export function contractTemplateCodeFor(
|
||||
tradeDirection?: string | null,
|
||||
freightType?: string | null,
|
||||
customsClearingEnabled?: boolean | null,
|
||||
ethiopianCustomsOnly?: boolean | null,
|
||||
): ContractTemplateCode {
|
||||
const direction =
|
||||
tradeDirection === "IMPORT"
|
||||
@@ -78,7 +86,11 @@ export function contractTemplateCodeFor(
|
||||
if (direction === "INTERCITY") {
|
||||
return `INTERCITY_${freight}` as ContractTemplateCode;
|
||||
}
|
||||
const customs = customsClearingEnabled ? "CUSTOMS" : "NO_CUSTOMS";
|
||||
const customs = customsClearingEnabled
|
||||
? ethiopianCustomsOnly
|
||||
? "ETHIOPIAN_CUSTOMS"
|
||||
: "CUSTOMS"
|
||||
: "NO_CUSTOMS";
|
||||
return `${direction}_${freight}_${customs}` as ContractTemplateCode;
|
||||
}
|
||||
|
||||
@@ -107,9 +119,16 @@ export function bulkTemplateCode(
|
||||
cargoCode: string,
|
||||
direction: BulkTemplateDirection,
|
||||
withCustoms: boolean | null,
|
||||
ethiopianCustomsOnly = false,
|
||||
): string {
|
||||
const suffix =
|
||||
direction === "INTERCITY" ? "" : withCustoms ? "_CUSTOMS" : "_NO_CUSTOMS";
|
||||
direction === "INTERCITY"
|
||||
? ""
|
||||
: withCustoms
|
||||
? ethiopianCustomsOnly
|
||||
? "_ETHIOPIAN_CUSTOMS"
|
||||
: "_CUSTOMS"
|
||||
: "_NO_CUSTOMS";
|
||||
return `BULK_${direction}_${cargoCode}${suffix}`.toUpperCase();
|
||||
}
|
||||
|
||||
@@ -162,7 +181,15 @@ export class ContractTemplate extends BaseEntity {
|
||||
@Column({ name: "with_customs", type: "boolean", nullable: true })
|
||||
withCustoms?: boolean | null;
|
||||
|
||||
/** The five seeded container templates — cannot be deleted. */
|
||||
/**
|
||||
* Bulk templates only: the with-customs variant restricted to Ethiopian-side
|
||||
* clearing (Djibouti stays with the Client). Only meaningful when
|
||||
* `withCustoms` is true; null/false otherwise.
|
||||
*/
|
||||
@Column({ name: "ethiopian_customs_only", type: "boolean", nullable: true })
|
||||
ethiopianCustomsOnly?: boolean | null;
|
||||
|
||||
/** The seeded container templates — cannot be deleted. */
|
||||
@Column({ name: "is_system", type: "boolean", default: false })
|
||||
isSystem!: boolean;
|
||||
}
|
||||
|
||||
@@ -227,3 +227,85 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The customer's shipment request is the order: GL may not change its container
|
||||
* sizes/quantities or billing currency at completion — only per-unit details.
|
||||
*/
|
||||
describe('ContractBookingService — shipment-request lock at completion', () => {
|
||||
type WithAssert = {
|
||||
assertMatchesShipmentRequest(
|
||||
bookingId: string,
|
||||
dto: {
|
||||
paymentCurrency?: string;
|
||||
containers?: Array<{ containerSize: string; quantity: number }>;
|
||||
bulkLines?: Array<{ cargoWeightTons?: number }>;
|
||||
},
|
||||
): Promise<void>;
|
||||
};
|
||||
|
||||
const serviceWithRequest = (request: unknown): WithAssert => {
|
||||
const svc = Object.create(ContractBookingService.prototype) as WithAssert & {
|
||||
dataSource: unknown;
|
||||
};
|
||||
svc.dataSource = {
|
||||
getRepository: () => ({ findOne: async () => request }),
|
||||
};
|
||||
return svc;
|
||||
};
|
||||
|
||||
const request = {
|
||||
paymentCurrency: 'USD',
|
||||
requestedLines: {
|
||||
containers: [
|
||||
{ containerSize: '20ft', quantity: 2 },
|
||||
{ containerSize: '40ft', quantity: 1 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
it('accepts the exact requested quantities and currency', async () => {
|
||||
await expect(
|
||||
serviceWithRequest(request).assertMatchesShipmentRequest('b1', {
|
||||
paymentCurrency: 'USD',
|
||||
containers: [
|
||||
{ containerSize: '40ft', quantity: 1 },
|
||||
{ containerSize: '20ft', quantity: 2 },
|
||||
],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects changed quantities', async () => {
|
||||
await expect(
|
||||
serviceWithRequest(request).assertMatchesShipmentRequest('b1', {
|
||||
paymentCurrency: 'USD',
|
||||
containers: [
|
||||
{ containerSize: '20ft', quantity: 4 },
|
||||
{ containerSize: '40ft', quantity: 1 },
|
||||
],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a changed billing currency', async () => {
|
||||
await expect(
|
||||
serviceWithRequest(request).assertMatchesShipmentRequest('b1', {
|
||||
paymentCurrency: 'ETB',
|
||||
containers: [
|
||||
{ containerSize: '20ft', quantity: 2 },
|
||||
{ containerSize: '40ft', quantity: 1 },
|
||||
],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('is a no-op without a linked request', async () => {
|
||||
await expect(
|
||||
serviceWithRequest(null).assertMatchesShipmentRequest('b1', {
|
||||
paymentCurrency: 'ETB',
|
||||
containers: [{ containerSize: '20ft', quantity: 9 }],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { DataSource } from 'typeorm';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||
import { CargoUnitOfMeasure } from '@edr/types';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||
@@ -28,6 +29,7 @@ import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-s
|
||||
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { bulkTonsPerWagon } from '../train-scheduling/train-capacity.util';
|
||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||
@@ -36,6 +38,7 @@ import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { hasFreightPermission } from '../../common/freight-permission.util';
|
||||
|
||||
import { BookingRequest } from './entities/booking-request.entity';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractRoute } from './entities/contract-route.entity';
|
||||
import {
|
||||
@@ -272,6 +275,8 @@ export class ContractBookingService {
|
||||
});
|
||||
}
|
||||
|
||||
const bulkFields = await this.resolveBulkCargoFields(contract, dto);
|
||||
|
||||
// Denormalize route/direction/freight onto the booking for the scheduling engine.
|
||||
// Retry past a concurrent insert that grabbed the same BK sequence number.
|
||||
const booking = await insertWithGeneratedReference(
|
||||
@@ -305,8 +310,7 @@ export class ContractBookingService {
|
||||
cargoFreeText: dto.cargoFreeText?.trim() || null,
|
||||
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
|
||||
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
|
||||
...bulkFields,
|
||||
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||
firstMilePickupLat: contract.firstMilePickupLat ?? null,
|
||||
firstMilePickupLng: contract.firstMilePickupLng ?? null,
|
||||
@@ -395,6 +399,10 @@ export class ContractBookingService {
|
||||
if (
|
||||
withContainers &&
|
||||
freightType === 'CONTAINER' &&
|
||||
// A rebooked cancellation credit carries `skipAutoConsolidation`: its
|
||||
// shared-wagon partner is picked by GL in the rebook flow, so nothing may
|
||||
// auto-claim (or park) it here behind GL's back.
|
||||
!dto.skipAutoConsolidation &&
|
||||
(await this.consolidationService.needsConsolidationFromBooking(
|
||||
withContainers,
|
||||
))
|
||||
@@ -848,6 +856,35 @@ export class ContractBookingService {
|
||||
if (!dto.scheduledDate) {
|
||||
throw new BadRequestException('A binding shipment day is required');
|
||||
}
|
||||
// Without-customs import/export: the customer's own clearing agent (name,
|
||||
// email, phone) is captured per booking at completion. A resubmit may omit
|
||||
// the fields and keep what the booking already stored. Customs contracts
|
||||
// (GL clears) and intercity (no border) never collect an agent.
|
||||
if (
|
||||
!contract.customsClearingEnabled &&
|
||||
contract.tradeDirection !== 'DOMESTIC'
|
||||
) {
|
||||
const agentName =
|
||||
dto.customsClearingAgent?.trim() || booking.customsClearingAgent || null;
|
||||
const agentEmail =
|
||||
dto.customsClearingAgentEmail?.trim() ||
|
||||
booking.customsClearingAgentEmail ||
|
||||
null;
|
||||
const agentPhone =
|
||||
dto.customsClearingAgentPhone?.trim() ||
|
||||
booking.customsClearingAgentPhone ||
|
||||
null;
|
||||
if (!agentName || !agentEmail || !agentPhone) {
|
||||
throw new BadRequestException(
|
||||
'Customs clearing agent name, email and phone are required to complete this booking.',
|
||||
);
|
||||
}
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
customsClearingAgent: agentName,
|
||||
customsClearingAgentEmail: agentEmail,
|
||||
customsClearingAgentPhone: agentPhone,
|
||||
} as never);
|
||||
}
|
||||
// No expiry gate here on purpose: this booking was already initiated
|
||||
// before the contract lapsed (createUnderContract/initiateUnderContract
|
||||
// already checked expiry at start). Finishing an in-flight booking must
|
||||
@@ -863,6 +900,12 @@ export class ContractBookingService {
|
||||
direction: contract.tradeDirection ?? null,
|
||||
});
|
||||
|
||||
// The customer's shipment request is the order: sizes, quantities and
|
||||
// billing currency are theirs — GL enters everything else. Both halves of a
|
||||
// consolidated pair pass through here, so each is checked against its OWN
|
||||
// request.
|
||||
await this.assertMatchesShipmentRequest(booking.id, dto);
|
||||
|
||||
const freightType = contract.freightType;
|
||||
let hasCargo =
|
||||
(booking.bookingContainers?.length ?? 0) > 0 ||
|
||||
@@ -944,8 +987,7 @@ export class ContractBookingService {
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||
cargoFreeText: dto.cargoFreeText?.trim() || null,
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
|
||||
...(await this.resolveBulkCargoFields(contract, dto)),
|
||||
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
||||
// Completion is where the cargo — and therefore the price — is fixed, so
|
||||
// it is also where the billing currency is chosen. A bare instance was
|
||||
@@ -1052,6 +1094,72 @@ export class ContractBookingService {
|
||||
return { booking: completed, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* The linked shipment request (customs Path B) is the customer's order:
|
||||
* container sizes + quantities and the billing currency are the customer's
|
||||
* choices, and GL may not change them at completion — only per-unit details
|
||||
* (numbers, seals, VGM, handling) are GL's to enter. No linked request, or a
|
||||
* legacy request without lines/currency ⇒ nothing to enforce. Container lines
|
||||
* are checked only when the payload restates cargo (a day-only resubmit keeps
|
||||
* the already-validated persisted cargo).
|
||||
*/
|
||||
private async assertMatchesShipmentRequest(
|
||||
bookingId: string,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
): Promise<void> {
|
||||
const request = await this.dataSource.getRepository(BookingRequest).findOne({
|
||||
where: { createdBookingId: bookingId },
|
||||
});
|
||||
if (!request) return;
|
||||
const lines = request.requestedLines ?? {};
|
||||
|
||||
if (request.paymentCurrency) {
|
||||
if (dto.paymentCurrency && dto.paymentCurrency !== request.paymentCurrency) {
|
||||
throw new BadRequestException(
|
||||
`The customer chose ${request.paymentCurrency} on the shipment request — the billing currency cannot be changed.`,
|
||||
);
|
||||
}
|
||||
dto.paymentCurrency = request.paymentCurrency;
|
||||
}
|
||||
|
||||
if (dto.containers?.length && lines.containers?.length) {
|
||||
// Compare per size in ft ("20ft" vs "20FT"/"20" spellings must not differ).
|
||||
const byFt = (rows: Array<{ containerSize: string; quantity: number }>) => {
|
||||
const map = new Map<number, number>();
|
||||
for (const row of rows) {
|
||||
const ft = parseInt(String(row.containerSize), 10);
|
||||
map.set(ft, (map.get(ft) ?? 0) + Number(row.quantity || 0));
|
||||
}
|
||||
return map;
|
||||
};
|
||||
const requested = byFt(lines.containers);
|
||||
const given = byFt(dto.containers);
|
||||
const same =
|
||||
requested.size === given.size &&
|
||||
[...requested].every(([ft, qty]) => given.get(ft) === qty);
|
||||
if (!same) {
|
||||
const summary = [...requested]
|
||||
.map(([ft, qty]) => `${qty} × ${ft}ft`)
|
||||
.join(', ');
|
||||
throw new BadRequestException(
|
||||
`The customer requested exactly ${summary} — container sizes and quantities cannot be changed at completion.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.bulkLines?.length && lines.bulk?.cargoWeightTons != null) {
|
||||
const givenTons = dto.bulkLines.reduce(
|
||||
(sum, l) => sum + Number(l.cargoWeightTons || 0),
|
||||
0,
|
||||
);
|
||||
if (givenTons !== Number(lines.bulk.cargoWeightTons)) {
|
||||
throw new BadRequestException(
|
||||
`The customer requested ${lines.bulk.cargoWeightTons} tons on the shipment request — the bulk quantity cannot be changed at completion.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for a complementary partner for a parked-eligible drawdown, pair it or
|
||||
* park it in PENDING_CONSOLIDATION with the resume status it should return to.
|
||||
@@ -1456,8 +1564,10 @@ export class ContractBookingService {
|
||||
return probe;
|
||||
}
|
||||
|
||||
probe.cargoTotalWeightVgm = this.resolveBulkTons(dto);
|
||||
probe.bulkTotalWeightTons = this.resolveBulkWeightTons(dto);
|
||||
const bulkFields = await this.resolveBulkCargoFields(contract, dto);
|
||||
probe.cargoTotalWeightVgm = bulkFields.cargoTotalWeightVgm;
|
||||
probe.bulkTotalWeightTons = bulkFields.bulkTotalWeightTons;
|
||||
probe.bulkRequestedWagons = bulkFields.bulkRequestedWagons;
|
||||
const cargoTypeId = this.resolveCargoTypeId(contract, dto);
|
||||
probe.cargoTypeId = cargoTypeId;
|
||||
if (cargoTypeId) {
|
||||
@@ -1861,6 +1971,107 @@ export class ContractBookingService {
|
||||
return tons > 0 ? tons : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk cargo columns for the booking row, resolved against the commodity's
|
||||
* unit of measure:
|
||||
*
|
||||
* - PER_TON: `cargoTotalWeightVgm` = tons (legacy behaviour).
|
||||
* - PER_ITEM: `cargoTotalWeightVgm` = item count, real tonnage in
|
||||
* `bulkTotalWeightTons` (legacy behaviour).
|
||||
* - NUMBER_OF_WAGONS: `cargoTotalWeightVgm` = tons, and the payload must fix
|
||||
* the wagon count (customer on the portal, GL in the backoffice). The
|
||||
* count is validated so each wagon's even share (tons ÷ wagons) fits what
|
||||
* one wagon of this cargo may carry; the optional item count is stored as
|
||||
* information only and never prices or sizes anything.
|
||||
*
|
||||
* Container contracts (and payloads without bulk lines) pass through with
|
||||
* the legacy zero/null values.
|
||||
*/
|
||||
private async resolveBulkCargoFields(
|
||||
contract: Contract,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
): Promise<{
|
||||
cargoTotalWeightVgm: number;
|
||||
bulkTotalWeightTons: number | null;
|
||||
bulkRequestedWagons: number | null;
|
||||
bulkItemCount: number | null;
|
||||
}> {
|
||||
const legacy = {
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
|
||||
bulkRequestedWagons: null as number | null,
|
||||
bulkItemCount: null as number | null,
|
||||
};
|
||||
if (contract.freightType === 'CONTAINER' || !dto.bulkLines?.length) {
|
||||
return legacy;
|
||||
}
|
||||
const cargoTypeId = this.resolveCargoTypeId(contract, dto);
|
||||
if (!cargoTypeId) return legacy;
|
||||
const cargoType = await this.dataSource.getRepository(CargoType).findOne({
|
||||
where: { id: cargoTypeId },
|
||||
relations: { wagonTypes: true },
|
||||
});
|
||||
if (cargoType?.unitOfMeasure !== CargoUnitOfMeasure.NumberOfWagons) {
|
||||
return legacy;
|
||||
}
|
||||
|
||||
const tons = dto.bulkLines.reduce(
|
||||
(sum, l) => sum + Number(l.cargoWeightTons ?? 0),
|
||||
0,
|
||||
);
|
||||
const items = dto.bulkLines.reduce(
|
||||
(sum, l) => sum + Number(l.itemCount ?? 0),
|
||||
0,
|
||||
);
|
||||
const wagons = Math.floor(Number(dto.requestedWagons ?? 0));
|
||||
if (!(wagons >= 1)) {
|
||||
throw new BadRequestException(
|
||||
`${cargoType.cargoTypeName} is booked by wagons — enter the number of wagons needed.`,
|
||||
);
|
||||
}
|
||||
if (!(tons > 0)) {
|
||||
throw new BadRequestException('Cargo weight in tons is required.');
|
||||
}
|
||||
this.assertWagonShareFits(cargoType, tons, wagons);
|
||||
return {
|
||||
cargoTotalWeightVgm: tons,
|
||||
bulkTotalWeightTons: null,
|
||||
bulkRequestedWagons: wagons,
|
||||
bulkItemCount: items > 0 ? Math.floor(items) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* NUMBER_OF_WAGONS: block the booking outright when the even per-wagon share
|
||||
* (tons ÷ requested wagons) is heavier than what ANY of the cargo's allowed
|
||||
* wagon types may carry — 100T on 2 wagons is 50T each and fine on a 60T
|
||||
* wagon, but 100T on 1 wagon can never ride. Cargo types with no wagon types
|
||||
* configured skip the check (allocation falls back to the default rating).
|
||||
*/
|
||||
private assertWagonShareFits(
|
||||
cargoType: CargoType,
|
||||
tons: number,
|
||||
wagons: number,
|
||||
): void {
|
||||
const allowed = (cargoType.wagonTypes ?? []).filter(
|
||||
(wt) => Number(wt.capacityTons) > 0,
|
||||
);
|
||||
if (!allowed.length) return;
|
||||
const maxPerWagon = Math.max(
|
||||
...allowed.map((wt) =>
|
||||
bulkTonsPerWagon(cargoType, wt.id, Number(wt.capacityTons)),
|
||||
),
|
||||
);
|
||||
const share = tons / wagons;
|
||||
if (share > maxPerWagon) {
|
||||
throw new BadRequestException(
|
||||
`${tons} tons across ${wagons} wagon(s) loads ${round3(share)}T per wagon, ` +
|
||||
`but a wagon of this cargo carries at most ${round3(maxPerWagon)}T — ` +
|
||||
`request at least ${Math.ceil(tons / maxPerWagon)} wagons.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-line handling counts. Each physical container carries its own hazardous
|
||||
* / reefer / return switch (entered next to its VGM), so the count is however
|
||||
@@ -2184,8 +2395,7 @@ export class ContractBookingService {
|
||||
contractRouteId: route?.id ?? null,
|
||||
originYardId: route?.originYardId ?? null,
|
||||
destinationYardId: route?.destinationYardId ?? null,
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
|
||||
...(await this.resolveBulkCargoFields(contract, dto)),
|
||||
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
|
||||
bookingContainers: resolved.map(({ line, ct, totalVgmTons }) =>
|
||||
|
||||
@@ -428,6 +428,8 @@ export class ContractTransitionService {
|
||||
contract.customsClearingEnabled,
|
||||
// Bulk templates are keyed by the contract's cargo type.
|
||||
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
|
||||
// Ethiopian-customs-only service types resolve to the Ethiopian variant.
|
||||
contract.serviceType?.includesEthiopianCustomsOnly,
|
||||
);
|
||||
if (!active) return null;
|
||||
return {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsEmail,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
@@ -208,6 +210,19 @@ export class CreateBookingUnderContractDto {
|
||||
@Type(() => CreateBulkLineDto)
|
||||
bulkLines?: CreateBulkLineDto[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
minimum: 1,
|
||||
description:
|
||||
'NUMBER_OF_WAGONS bulk cargo only: how many wagons the shipment needs. ' +
|
||||
'The weight spreads evenly across them; a PER_WAGON rate bills this count. ' +
|
||||
'Required when the cargo type is measured by wagons, ignored otherwise.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
|
||||
requestedWagons?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'What the containers carry — captured per booking (container freight).',
|
||||
})
|
||||
@@ -215,6 +230,29 @@ export class CreateBookingUnderContractDto {
|
||||
@IsString()
|
||||
cargoFreeText?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
maxLength: 200,
|
||||
description:
|
||||
'Customs clearing agent name. Required at completion of a without-customs ' +
|
||||
'import/export booking (the service enforces it); ignored on customs contracts.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
customsClearingAgent?: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 200, description: 'Customs clearing agent email.' })
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
@MaxLength(200)
|
||||
customsClearingAgentEmail?: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 50, description: 'Customs clearing agent phone number.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
customsClearingAgentPhone?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -63,6 +63,12 @@ describe('shipment preview / created booking parity', () => {
|
||||
resolveShipmentEquipmentReturn: () => c.equipmentReturn,
|
||||
resolveBulkTons: () => 0,
|
||||
resolveBulkWeightTons: () => 0,
|
||||
resolveBulkCargoFields: async () => ({
|
||||
cargoTotalWeightVgm: 0,
|
||||
bulkTotalWeightTons: null,
|
||||
bulkRequestedWagons: null,
|
||||
bulkItemCount: null,
|
||||
}),
|
||||
resolveContainerTypeForSize: async () => ({ id: 'ct40', sizeFt: 40 }),
|
||||
handlingCounts: () => ({
|
||||
hazardousQuantity: 0,
|
||||
|
||||
@@ -118,7 +118,11 @@ export class EimsSellerCacheService implements OnModuleInit {
|
||||
woreda: data.woreda,
|
||||
});
|
||||
this.cached = {
|
||||
LegalName: data.companyName || undefined,
|
||||
// The *legal* entity name, not the licence's trade name that
|
||||
// `data.companyName` now carries — an EIMS seller is filed under its
|
||||
// registered name.
|
||||
LegalName:
|
||||
companyInfo?.BusinessName?.trim() || data.companyName || undefined,
|
||||
Phone: data.mobilePhone || data.regularPhone || undefined,
|
||||
Region: geo?.Region,
|
||||
Wereda: geo?.Wereda,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
import { FreightJwtGuard } from '../../common/freight-jwt.guard';
|
||||
import type { Response } from 'express';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
@@ -57,7 +57,7 @@ const toCatalogEntry = (dataset: ExportDataset): ExportCatalogEntry => ({
|
||||
@ApiTags('Exports')
|
||||
@ApiBearerAuth()
|
||||
@Controller('exports')
|
||||
@UseGuards(JwtGuard)
|
||||
@UseGuards(FreightJwtGuard)
|
||||
export class ExportsController {
|
||||
constructor(
|
||||
private readonly runner: ExportRunnerService,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
|
||||
import { FreightJwtGuard } from "../../common/freight-jwt.guard";
|
||||
|
||||
import {
|
||||
AuthUserPayload,
|
||||
@@ -21,7 +21,7 @@ import { NotificationInboxService } from "./notification-inbox.service";
|
||||
|
||||
@ApiTags("notifications")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtGuard)
|
||||
@UseGuards(FreightJwtGuard)
|
||||
@Controller("notifications")
|
||||
export class NotificationInboxController {
|
||||
constructor(private readonly service: NotificationInboxService) {}
|
||||
|
||||
@@ -34,10 +34,6 @@ import {
|
||||
directionScopeSql,
|
||||
} from "../user-trade-access/trade-scope.util";
|
||||
|
||||
/** Bookings carry a contract_kind column; GENERAL = umbrella contract row, not a shipment. */
|
||||
const EXCLUDE_GENERAL_CONTRACT_BOOKINGS =
|
||||
"(booking.contract_kind IS NULL OR booking.contract_kind <> 'GENERAL')";
|
||||
|
||||
export type OverviewBookingKpisRow = {
|
||||
total: number;
|
||||
totalActive: number;
|
||||
@@ -147,7 +143,6 @@ export class OverviewRepository {
|
||||
"submittedToday",
|
||||
)
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.setParameters({
|
||||
closedStatuses: [...OVERVIEW_CLOSED_STATUSES],
|
||||
@@ -337,7 +332,6 @@ export class OverviewRepository {
|
||||
.select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, "date")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
||||
.groupBy("booking.created_at::date")
|
||||
@@ -357,7 +351,6 @@ export class OverviewRepository {
|
||||
.select("booking.status", "status")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.groupBy("booking.status")
|
||||
.getRawMany<{ status: string; count: string }>();
|
||||
@@ -427,7 +420,6 @@ export class OverviewRepository {
|
||||
.addSelect("booking.payment_currency", "paymentCurrency")
|
||||
.addSelect("booking.created_at", "createdAt")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.orderBy("booking.created_at", "DESC")
|
||||
.limit(limit)
|
||||
@@ -463,7 +455,6 @@ export class OverviewRepository {
|
||||
.select("booking.freight_type", "label")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere("booking.status != 'DRAFT'")
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.groupBy("booking.freight_type")
|
||||
@@ -485,7 +476,6 @@ export class OverviewRepository {
|
||||
.select("booking.payment_currency", "label")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere("booking.status != 'DRAFT'")
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.groupBy("booking.payment_currency")
|
||||
@@ -602,7 +592,6 @@ export class OverviewRepository {
|
||||
this.bookingRepository
|
||||
.createQueryBuilder("booking")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere(bookingScope.sql, bookingScope.params)
|
||||
.andWhere(windowSql("booking.created_at"), { days, offsetDays })
|
||||
.getCount(),
|
||||
@@ -802,7 +791,6 @@ export class OverviewRepository {
|
||||
.addSelect("FLOOR(EXTRACT(HOUR FROM booking.created_at) / 3)::int", "block")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
||||
.groupBy("EXTRACT(ISODOW FROM booking.created_at)::int")
|
||||
@@ -1457,7 +1445,6 @@ export class OverviewRepository {
|
||||
ON y.id = CASE WHEN b.trade_direction = 'EXPORT'
|
||||
THEN b.destination_yard_id ELSE b.origin_yard_id END
|
||||
WHERE b.deleted_at IS NULL
|
||||
AND (b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')
|
||||
AND b.created_at >= NOW() - make_interval(days => $1::int)
|
||||
GROUP BY 1
|
||||
ORDER BY count DESC
|
||||
@@ -1475,7 +1462,6 @@ export class OverviewRepository {
|
||||
ON y.id = CASE WHEN b.trade_direction = 'EXPORT'
|
||||
THEN b.destination_yard_id ELSE b.origin_yard_id END
|
||||
WHERE b.deleted_at IS NULL
|
||||
AND (b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')
|
||||
AND b.created_at >= NOW() - make_interval(days => $1::int)
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1, 2
|
||||
|
||||
@@ -2,8 +2,10 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { Invoice } from '../../billing/entities/invoice.entity';
|
||||
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
|
||||
import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import { CURRENCY_FILTER, PAYER_EXPR, currencyOf } from '../revenue-classification';
|
||||
|
||||
const OPEN_STATUSES = ['ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE'];
|
||||
|
||||
@@ -13,13 +15,22 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
// to now() in SQL when the filter is unset (see the COALESCE below).
|
||||
const asOf = (params.asOf as string | null) ?? null;
|
||||
|
||||
// Both payer joins are LEFT: an invoice billed to a shipping line carries no
|
||||
// company, and an INNER join on `companies` silently drops its balance out of
|
||||
// the arrears total.
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(Invoice, 'i')
|
||||
.innerJoin(Company, 'c', 'c.id = i.company_id')
|
||||
.leftJoin(Company, 'c', 'c.id = i.company_id')
|
||||
.leftJoin(ShippingLineCompany, 'slc', 'slc.id = i.shipping_line_company_id')
|
||||
.where('i.deleted_at IS NULL')
|
||||
.andWhere('i.status IN (:...openStatuses)', { openStatuses: OPEN_STATUSES })
|
||||
.andWhere('i.balance_amount > 0')
|
||||
// Stored casing has drifted ("usd" rows exist), and one arrears figure
|
||||
// cannot span two currencies.
|
||||
.andWhere('UPPER(i.currency) = :currency', {
|
||||
currency: currencyOf(params).toUpperCase(),
|
||||
})
|
||||
.setParameter('asOf', asOf);
|
||||
|
||||
// ACL: invoices.source_id is a varchar pointer at the originating booking.
|
||||
@@ -32,9 +43,15 @@ export const agingReceivablesReport: ReportDefinition = {
|
||||
title: 'Aging Receivables',
|
||||
description: 'Outstanding customer balances bucketed by days overdue',
|
||||
group: 'Finance',
|
||||
filters: [{ key: 'asOf', label: 'As of', type: 'date' }],
|
||||
filters: [{ key: 'asOf', label: 'As of', type: 'date' }, CURRENCY_FILTER],
|
||||
columns: [
|
||||
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
|
||||
{
|
||||
key: 'customer',
|
||||
label: 'Customer',
|
||||
type: 'string',
|
||||
sortable: true,
|
||||
sortExpr: PAYER_EXPR,
|
||||
},
|
||||
{ key: 'invoices', label: 'Invoices', type: 'number' },
|
||||
{ key: 'outstanding', label: 'Outstanding', type: 'money', sortable: true },
|
||||
{ key: 'current', label: 'Current', type: 'money' },
|
||||
@@ -46,7 +63,7 @@ export const agingReceivablesReport: ReportDefinition = {
|
||||
defaultSort: { key: 'outstanding', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select('c.name', 'customer')
|
||||
.select(PAYER_EXPR, 'customer')
|
||||
.addSelect('COUNT(*)::int', 'invoices')
|
||||
.addSelect('ROUND(SUM(i.balance_amount))::float8', 'outstanding')
|
||||
.addSelect(
|
||||
@@ -72,15 +89,19 @@ export const agingReceivablesReport: ReportDefinition = {
|
||||
`ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE(:asOf::timestamptz, now()) - interval '90 days'), 0))::float8`,
|
||||
'overdue90plus',
|
||||
)
|
||||
.groupBy('c.name');
|
||||
.groupBy(PAYER_EXPR);
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'outstanding')
|
||||
.addSelect('COUNT(DISTINCT c.id)::int', 'customers')
|
||||
.addSelect(`COUNT(DISTINCT ${PAYER_EXPR})::int`, 'customers')
|
||||
.getRawOne();
|
||||
return [
|
||||
{ label: 'Outstanding', value: Number(row?.outstanding ?? 0), unit: 'ETB' },
|
||||
{
|
||||
label: 'Outstanding',
|
||||
value: Number(row?.outstanding ?? 0),
|
||||
unit: currencyOf(ctx.params),
|
||||
},
|
||||
{ label: 'Customers with balance', value: Number(row?.customers ?? 0) },
|
||||
];
|
||||
},
|
||||
|
||||
@@ -4,19 +4,20 @@ import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import {
|
||||
ACTUAL_TONS_EXPR,
|
||||
CARGO_CATEGORY_EXPR,
|
||||
CARGO_CATEGORY_FILTER,
|
||||
CARGO_CATEGORY_LABEL_EXPR,
|
||||
ALLOC_CONTAINERS_20,
|
||||
ALLOC_CONTAINERS_40,
|
||||
CHARGED_TONS_EXPR,
|
||||
LOADED_WAGONS_EXPR,
|
||||
OPERATIONS_FILTERS,
|
||||
SCHEDULE_EMPTY_WAGONS,
|
||||
REVENUE_CARGO_CATEGORY_EXPR,
|
||||
REVENUE_CARGO_FILTER,
|
||||
SCHEDULE_KM_EXPR,
|
||||
TEU_EXPR,
|
||||
allocationLedgerQb,
|
||||
applyCategoryFilter,
|
||||
distanceKmBetween,
|
||||
} from '../operations-classification';
|
||||
import { CATEGORY_LABEL_OF } from '../revenue-classification';
|
||||
|
||||
/**
|
||||
* A leg is one station-to-station move the train actually made: two consecutive
|
||||
@@ -47,13 +48,34 @@ const LEG_FROM = 'COALESCE(leg.from_yard_id, ts.origin_station_id)';
|
||||
const LEG_TO = 'COALESCE(leg.to_yard_id, ts.destination_station_id)';
|
||||
|
||||
/**
|
||||
* Distance and empty-wagon count are constant within a group that includes
|
||||
* `ts.id` and the leg — MAX() satisfies Postgres without dragging a scalar
|
||||
* subselect through the GROUP BY.
|
||||
* Distance is constant within a group that includes `ts.id` and the leg —
|
||||
* MAX() satisfies Postgres without dragging a scalar subselect through the
|
||||
* GROUP BY.
|
||||
*/
|
||||
const LEG_KM = `MAX(${distanceKmBetween(LEG_FROM, LEG_TO)})`;
|
||||
const EMPTY_WAGONS = `MAX(${SCHEDULE_EMPTY_WAGONS})`;
|
||||
const TOTAL_WAGONS = `(COUNT(DISTINCT tsw.id) + ${EMPTY_WAGONS})::int`;
|
||||
|
||||
/**
|
||||
* The ledger carries the empty wagons as rows of their own, so both counts are
|
||||
* plain aggregates over the group: an empty-wagon row has no loaded wagons and
|
||||
* a cargo row has no empty ones. Read down a departure's rows and its wagons
|
||||
* add up once, instead of every row repeating the train's empty total.
|
||||
*/
|
||||
const EMPTY_WAGONS = 'COUNT(DISTINCT tsw.id) FILTER (WHERE wba.id IS NULL)';
|
||||
const TOTAL_WAGONS = 'COUNT(DISTINCT tsw.id)::int';
|
||||
|
||||
/**
|
||||
* Cargo in the revenue vocabulary, plus the wagon that carried none.
|
||||
*
|
||||
* The label is built out of the key expression rather than beside it: Postgres
|
||||
* only accepts an aggregate-query column inside a GROUP BY expression it can
|
||||
* match verbatim, so a second `wba.id IS NULL` test of its own would demand
|
||||
* `wba.id` in the GROUP BY — which would split the grain down to one row per
|
||||
* allocation.
|
||||
*/
|
||||
const CATEGORY_EXPR = `CASE WHEN wba.id IS NULL THEN 'EMPTY_WAGON'
|
||||
ELSE ${REVENUE_CARGO_CATEGORY_EXPR} END`;
|
||||
const CATEGORY_LABEL_EXPR = `CASE WHEN (${CATEGORY_EXPR}) = 'EMPTY_WAGON' THEN 'Empty wagon'
|
||||
ELSE ${CATEGORY_LABEL_OF(CATEGORY_EXPR)} END`;
|
||||
|
||||
/**
|
||||
* Ton/Km and Vehicle-Km are NULL — not zero — when the yard pair has no
|
||||
@@ -64,8 +86,8 @@ const TON_KM = `ROUND((${CHARGED_TONS_EXPR})::numeric * ${LEG_KM}, 1)::float8`;
|
||||
const VEHICLE_KM = `ROUND(${EMPTY_WAGONS}::numeric * ${LEG_KM}, 1)::float8`;
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const qb = allocationLedgerQb(ctx);
|
||||
applyCategoryFilter(qb, ctx.params);
|
||||
const qb = allocationLedgerQb(ctx, { includeEmptyWagons: true });
|
||||
applyCategoryFilter(qb, ctx.params, CATEGORY_EXPR);
|
||||
return qb;
|
||||
}
|
||||
|
||||
@@ -89,20 +111,25 @@ export const chargedVsActualVolumeReport: ReportDefinition = {
|
||||
'corridor. Charged volume is the standard weight capacity — 20 and 40 tons per laden ' +
|
||||
'container, 2.24 and 3.88 empty, 70 tons per wagon of steel or fertilizer, 38 for ' +
|
||||
'perishables — all editable in Operating standards. Actual volume is what the ' +
|
||||
'marshalling recorded. Volumes and wagon counts belong to the train, not to the leg, ' +
|
||||
'so they repeat on every leg it ran and across its cargo types rather than being split ' +
|
||||
'between them — the KPIs above count each train once. Ton/Km and Vehicle-Km are the ' +
|
||||
'exception and are the leg’s own, so they add up across legs into the real corridor ' +
|
||||
'figure.',
|
||||
'marshalling recorded. Cargo types are the revenue categories the money side bills ' +
|
||||
'against, so a corridor’s tonnage and its revenue read in the same buckets; wagons ' +
|
||||
'that carried nothing are their own “Empty wagon” line. Volumes and wagon counts ' +
|
||||
'belong to the train, not to the leg, so they repeat on every leg it ran rather than ' +
|
||||
'being split between them — the KPIs above count each train once. Ton/Km and ' +
|
||||
'Vehicle-Km are the exception and are the leg’s own, so they add up across legs into ' +
|
||||
'the real corridor figure.',
|
||||
group: 'Operations',
|
||||
filters: [...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER],
|
||||
filters: [...OPERATIONS_FILTERS, REVENUE_CARGO_FILTER],
|
||||
columns: [
|
||||
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' },
|
||||
{ key: 'departedAt', label: 'Departure', type: 'date', sortable: true, sortExpr: 'ts.scheduled_departure_date' },
|
||||
{ key: 'leg', label: 'Leg', type: 'string' },
|
||||
{ key: 'category', label: 'Cargo type', type: 'string', sortable: true, sortExpr: CARGO_CATEGORY_EXPR },
|
||||
{ key: 'legFrom', label: 'From', type: 'string', sortable: true, sortExpr: 'COALESCE(lfy.label, lfy.code)' },
|
||||
{ key: 'legTo', label: 'To', type: 'string', sortable: true, sortExpr: 'COALESCE(lty.label, lty.code)' },
|
||||
{ key: 'category', label: 'Cargo type', type: 'string', sortable: true, sortExpr: CATEGORY_EXPR },
|
||||
{ key: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true },
|
||||
{ key: 'actualTons', label: 'Actual volume', type: 'tons', sortable: true },
|
||||
{ key: 'containers20', label: '20ft', type: 'number', sortable: true },
|
||||
{ key: 'containers40', label: '40ft', type: 'number', sortable: true },
|
||||
{ key: 'teu', label: 'TEU', type: 'number' },
|
||||
{ key: 'wagons', label: 'Loaded wagons', type: 'number' },
|
||||
{ key: 'emptyWagons', label: 'Empty wagons', type: 'number' },
|
||||
@@ -116,10 +143,13 @@ export const chargedVsActualVolumeReport: ReportDefinition = {
|
||||
return legQuery(ctx)
|
||||
.select("COALESCE(ts.train_number, '—')", 'trainNumber')
|
||||
.addSelect(`to_char(COALESCE(ts.actual_departure_at, ts.scheduled_departure_date), 'YYYY-MM-DD HH24:MI')`, 'departedAt')
|
||||
.addSelect("COALESCE(lfy.label, lfy.code, '?') || ' → ' || COALESCE(lty.label, lty.code, '?')", 'leg')
|
||||
.addSelect(CARGO_CATEGORY_LABEL_EXPR, 'category')
|
||||
.addSelect("COALESCE(lfy.label, lfy.code, '?')", 'legFrom')
|
||||
.addSelect("COALESCE(lty.label, lty.code, '?')", 'legTo')
|
||||
.addSelect(CATEGORY_LABEL_EXPR, 'category')
|
||||
.addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 2)::float8`, 'chargedTons')
|
||||
.addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 2)::float8`, 'actualTons')
|
||||
.addSelect(`COALESCE(SUM(${ALLOC_CONTAINERS_20}), 0)::int`, 'containers20')
|
||||
.addSelect(`COALESCE(SUM(${ALLOC_CONTAINERS_40}), 0)::int`, 'containers40')
|
||||
.addSelect(TEU_EXPR, 'teu')
|
||||
.addSelect(LOADED_WAGONS_EXPR, 'wagons')
|
||||
.addSelect(`${EMPTY_WAGONS}::int`, 'emptyWagons')
|
||||
@@ -137,7 +167,7 @@ export const chargedVsActualVolumeReport: ReportDefinition = {
|
||||
.addGroupBy('lfy.code')
|
||||
.addGroupBy('lty.label')
|
||||
.addGroupBy('lty.code')
|
||||
.addGroupBy(CARGO_CATEGORY_EXPR);
|
||||
.addGroupBy(CATEGORY_EXPR);
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
|
||||
@@ -2,19 +2,34 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { Freight } from '@edr/types';
|
||||
import { Invoice } from '../../billing/entities/invoice.entity';
|
||||
import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import { CURRENCY_FILTER, currencyOf } from '../revenue-classification';
|
||||
|
||||
const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((v) => ({ value: v, label: v }));
|
||||
const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
}));
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params } = ctx;
|
||||
const qb = ctx.ds.createQueryBuilder().from(Invoice, 'i').where('i.deleted_at IS NULL');
|
||||
const { params, directions } = ctx;
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(Invoice, 'i')
|
||||
.where('i.deleted_at IS NULL')
|
||||
// Both currencies live in this table; one money column cannot hold both.
|
||||
.andWhere('UPPER(i.currency) = :currency', {
|
||||
currency: currencyOf(params).toUpperCase(),
|
||||
});
|
||||
|
||||
if (params.dateFrom) qb.andWhere('i.created_at >= :dateFrom', { dateFrom: params.dateFrom });
|
||||
if (params.dateTo) qb.andWhere('i.created_at < :dateTo', { dateTo: params.dateTo });
|
||||
const statuses = params.statuses as string[] | null;
|
||||
if (statuses) qb.andWhere('i.status IN (:...statuses)', { statuses });
|
||||
return qb;
|
||||
|
||||
// Every other Finance report scopes by the caller's trade directions; without
|
||||
// it this one reports the value of invoices its reader may not see.
|
||||
return applyBookingRefDirectionScope(qb, 'i.source_id', directions);
|
||||
}
|
||||
|
||||
export const invoicingPipelineReport: ReportDefinition = {
|
||||
@@ -24,7 +39,13 @@ export const invoicingPipelineReport: ReportDefinition = {
|
||||
group: 'Finance',
|
||||
filters: [
|
||||
{ key: 'date', label: 'Created', type: 'daterange' },
|
||||
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
|
||||
CURRENCY_FILTER,
|
||||
{
|
||||
key: 'statuses',
|
||||
label: 'Status',
|
||||
type: 'multiselect',
|
||||
options: STATUS_OPTIONS,
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{ key: 'type', label: 'Type', type: 'string', sortable: true },
|
||||
@@ -52,8 +73,16 @@ export const invoicingPipelineReport: ReportDefinition = {
|
||||
.getRawOne();
|
||||
return [
|
||||
{ label: 'Invoices', value: Number(row?.invoices ?? 0) },
|
||||
{ label: 'Total value', value: Number(row?.totalAmount ?? 0), unit: 'ETB' },
|
||||
{ label: 'Outstanding', value: Number(row?.balance ?? 0), unit: 'ETB' },
|
||||
{
|
||||
label: 'Total value',
|
||||
value: Number(row?.totalAmount ?? 0),
|
||||
unit: currencyOf(ctx.params),
|
||||
},
|
||||
{
|
||||
label: 'Outstanding',
|
||||
value: Number(row?.balance ?? 0),
|
||||
unit: currencyOf(ctx.params),
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { visibleColumns } from '../report-runner.service';
|
||||
import { loadingUnloadingReport as def } from './loading-unloading.report';
|
||||
|
||||
/**
|
||||
* The two grains select different columns — per train the stop's own times,
|
||||
* per station the averages over it. A column shown under a grain its query
|
||||
* doesn't select is a blank column; a column SORTED under one is a 42703.
|
||||
*/
|
||||
describe('loading-unloading', () => {
|
||||
const keys = (grain: string) => visibleColumns(def, { grain }).map((c) => c.key);
|
||||
|
||||
it('shows the stop times per train and the averages per station, never both', () => {
|
||||
expect(keys('train')).toEqual(expect.arrayContaining(['arrivedAt', 'loadUnloadHours']));
|
||||
expect(keys('train')).not.toEqual(expect.arrayContaining(['avgLoadUnloadHours', 'stops']));
|
||||
expect(keys('station')).toEqual(expect.arrayContaining(['avgLoadUnloadHours', 'stops']));
|
||||
expect(keys('station')).not.toEqual(expect.arrayContaining(['arrivedAt', 'trainNumber']));
|
||||
});
|
||||
|
||||
it('sorts by a column both grains select, so the default sort never 42703s', () => {
|
||||
for (const grain of ['train', 'station']) {
|
||||
expect(keys(grain)).toContain(def.defaultSort!.key);
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults the grain, so an unset filter can't show the wrong half", () => {
|
||||
const grain = def.filters.find((f) => f.key === 'grain')!.defaultValue;
|
||||
expect(grain).toBe('train');
|
||||
expect(keys(grain!)).toEqual(keys('train'));
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
cycleRateExpr,
|
||||
handlingHours,
|
||||
hoursBetween,
|
||||
loadingEnd,
|
||||
loadingHours,
|
||||
loadingSource,
|
||||
loadingStart,
|
||||
otherActivityHours,
|
||||
stationStaysQb,
|
||||
unloadingHours,
|
||||
@@ -18,9 +20,12 @@ import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-class
|
||||
* Loading and unloading per train — the spec's own report format: train number,
|
||||
* total loading and unloading time, other activity, station staying time.
|
||||
*
|
||||
* The staying-time report publishes one row per individual stop; this one rolls
|
||||
* a train's stops up into the chosen period, which is what "for week report,
|
||||
* calculate average in the week" asks for. The station stays in the grain
|
||||
* Two shapes, one definition. Per train the row is the stop itself: the logged
|
||||
* arrival, departure, unloading and loading times and that stop's own
|
||||
* durations, because the train number is what makes a specific stop worth
|
||||
* naming. Per station it rolls up into the chosen period — one row per station,
|
||||
* averaged over every train that called there, which is what "for week report,
|
||||
* calculate average in the week" asks for. The station stays in both grains
|
||||
* because a train works both ends of the corridor and the standard it is judged
|
||||
* against differs by side (10h Ethiopia, 13h Djibouti) — averaging a train's
|
||||
* Nagad and Gelan stops together would compare that mixture to one standard.
|
||||
@@ -56,12 +61,20 @@ const GRAIN_FILTER: ReportFilterDef = {
|
||||
key: 'grain',
|
||||
label: 'Group by',
|
||||
type: 'select',
|
||||
defaultValue: 'train',
|
||||
options: [
|
||||
{ value: 'train', label: 'Train' },
|
||||
{ value: 'station', label: 'Station' },
|
||||
],
|
||||
};
|
||||
|
||||
/** Per train the rows are stops, so they carry times; per station, averages. */
|
||||
const TRAIN_ONLY = { grain: 'station' };
|
||||
const STATION_ONLY = { grain: 'train' };
|
||||
|
||||
/** Same display as the staying-time report, so a stop reads alike in both. */
|
||||
const at = (expr: string): string => `to_char(${expr}, 'YYYY-MM-DD HH24:MI')`;
|
||||
|
||||
/** Whitelisted here, so the user's value never reaches SQL. */
|
||||
const byStation = (ctx: ReportContext): boolean => ctx.params.grain === 'station';
|
||||
|
||||
@@ -69,9 +82,10 @@ export const loadingUnloadingReport: ReportDefinition = {
|
||||
key: 'loading-unloading',
|
||||
title: 'Loading & Unloading',
|
||||
description:
|
||||
'Loading and unloading per train, at the granularity you choose — one row per train per ' +
|
||||
'station per period, which at week or month grain is that train’s average over its stops ' +
|
||||
'in the period, the way the OCC report publishes it. Total loading and unloading is ' +
|
||||
'Loading and unloading, at the granularity you choose. Grouped by Train the row is one ' +
|
||||
'stop — its logged arrival, departure, unloading and loading times and that stop’s own ' +
|
||||
'durations. Grouped by Station it is one row per station per period, averaged over every ' +
|
||||
'train that called there, the way the OCC report publishes it. Total loading and unloading is ' +
|
||||
'the stop’s handling window, unloading start to loading end, which is the container ' +
|
||||
'measure; the unloading and loading columns split it for bulk stations that only do ' +
|
||||
'one of the two (Nagad, BCC and DMP on the Djibouti side; Sebeta, GMP, Adama and Modjo ' +
|
||||
@@ -95,74 +109,241 @@ export const loadingUnloadingReport: ReportDefinition = {
|
||||
],
|
||||
columns: [
|
||||
{ key: 'period', label: 'Period', type: 'string', sortable: true },
|
||||
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true },
|
||||
{
|
||||
key: 'trainNumber',
|
||||
label: 'Train No.',
|
||||
type: 'string',
|
||||
sortable: true,
|
||||
hideWhen: TRAIN_ONLY,
|
||||
},
|
||||
{ key: 'station', label: 'Station', type: 'string', sortable: true },
|
||||
{ key: 'country', label: 'Country', type: 'string' },
|
||||
{ key: 'trainType', label: 'Train type', type: 'string' },
|
||||
{ key: 'stops', label: 'Stops', type: 'number', sortable: true },
|
||||
{ key: 'handlingMeasured', label: 'Handling measured', type: 'number' },
|
||||
// Per station this would be a MAX over whatever mix of trains called there.
|
||||
{
|
||||
key: 'trainType',
|
||||
label: 'Train type',
|
||||
type: 'string',
|
||||
hideWhen: TRAIN_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'stops',
|
||||
label: 'Stops',
|
||||
type: 'number',
|
||||
sortable: true,
|
||||
hideWhen: STATION_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'handlingMeasured',
|
||||
label: 'Handling measured',
|
||||
type: 'number',
|
||||
hideWhen: STATION_ONLY,
|
||||
},
|
||||
{ key: 'loadingSource', label: 'Loading from', type: 'string' },
|
||||
{ key: 'avgUnloadingHours', label: 'Avg unloading (hrs)', type: 'number', sortable: true },
|
||||
{ key: 'avgLoadingHours', label: 'Avg loading (hrs)', type: 'number', sortable: true },
|
||||
// Per train: this stop's own clock, not a mean of several.
|
||||
{
|
||||
key: 'arrivedAt',
|
||||
label: 'Arrived',
|
||||
type: 'date',
|
||||
sortable: true,
|
||||
hideWhen: TRAIN_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'departedAt',
|
||||
label: 'Departed',
|
||||
type: 'date',
|
||||
hideWhen: TRAIN_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'unloadingStartedAt',
|
||||
label: 'Unloading start',
|
||||
type: 'date',
|
||||
hideWhen: TRAIN_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'unloadingCompletedAt',
|
||||
label: 'Unloading end',
|
||||
type: 'date',
|
||||
hideWhen: TRAIN_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'loadingStartedAt',
|
||||
label: 'Loading start',
|
||||
type: 'date',
|
||||
hideWhen: TRAIN_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'loadingCompletedAt',
|
||||
label: 'Loading end',
|
||||
type: 'date',
|
||||
hideWhen: TRAIN_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'unloadingHours',
|
||||
label: 'Unloading (hrs)',
|
||||
type: 'number',
|
||||
sortable: true,
|
||||
hideWhen: TRAIN_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'loadingHours',
|
||||
label: 'Loading (hrs)',
|
||||
type: 'number',
|
||||
sortable: true,
|
||||
hideWhen: TRAIN_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'loadUnloadHours',
|
||||
label: 'Loading + unloading (hrs)',
|
||||
type: 'number',
|
||||
sortable: true,
|
||||
hideWhen: TRAIN_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'otherActivityHours',
|
||||
label: 'Other activity (hrs)',
|
||||
type: 'number',
|
||||
hideWhen: TRAIN_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'stayingHours',
|
||||
label: 'Staying (hrs)',
|
||||
type: 'number',
|
||||
sortable: true,
|
||||
hideWhen: TRAIN_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'avgUnloadingHours',
|
||||
label: 'Avg unloading (hrs)',
|
||||
type: 'number',
|
||||
sortable: true,
|
||||
hideWhen: STATION_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'avgLoadingHours',
|
||||
label: 'Avg loading (hrs)',
|
||||
type: 'number',
|
||||
sortable: true,
|
||||
hideWhen: STATION_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'avgLoadUnloadHours',
|
||||
label: 'Avg loading + unloading (hrs)',
|
||||
type: 'number',
|
||||
sortable: true,
|
||||
hideWhen: STATION_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'avgOtherActivityHours',
|
||||
label: 'Avg other activity (hrs)',
|
||||
type: 'number',
|
||||
hideWhen: STATION_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'avgStayingHours',
|
||||
label: 'Avg staying (hrs)',
|
||||
type: 'number',
|
||||
sortable: true,
|
||||
hideWhen: STATION_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'stayStandardHours',
|
||||
label: 'Staying standard (hrs)',
|
||||
type: 'number',
|
||||
},
|
||||
{ key: 'avgOtherActivityHours', label: 'Avg other activity (hrs)', type: 'number' },
|
||||
{ key: 'avgStayingHours', label: 'Avg staying (hrs)', type: 'number', sortable: true },
|
||||
{ key: 'stayStandardHours', label: 'Staying standard (hrs)', type: 'number' },
|
||||
{ key: 'stayVerdict', label: 'Staying verdict', type: 'string' },
|
||||
{ key: 'handlingStandardHours', label: 'Handling standard (hrs)', type: 'number' },
|
||||
{ key: 'handlingRate', label: 'Handling rate', type: 'percent', sortable: true },
|
||||
{
|
||||
key: 'handlingStandardHours',
|
||||
label: 'Handling standard (hrs)',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
key: 'handlingRate',
|
||||
label: 'Handling rate',
|
||||
type: 'percent',
|
||||
sortable: true,
|
||||
},
|
||||
],
|
||||
defaultSort: { key: 'period', dir: 'DESC' },
|
||||
chart: { type: 'bar', x: 'trainNumber', y: ['avgLoadUnloadHours'] },
|
||||
// Only plottable at station grain — per train the rows are individual stops,
|
||||
// and the frontend drops the chart toggle when its columns are hidden.
|
||||
chart: { type: 'bar', x: 'station', y: ['avgLoadUnloadHours'] },
|
||||
query(ctx) {
|
||||
const { params } = ctx;
|
||||
|
||||
// Per train the row IS the stop: its own logged times and its own
|
||||
// durations, since an average of one stop is just the stop with the clock
|
||||
// thrown away. Averaging starts where the grain stops naming the train.
|
||||
if (!byStation(ctx)) {
|
||||
return stationStaysQb(ctx)
|
||||
.select(periodExprOn('s.arrived_at', params), 'period')
|
||||
.addSelect(TRAIN_NUMBER, 'trainNumber')
|
||||
.addSelect('s.station', 'station')
|
||||
.addSelect('s.country', 'country')
|
||||
.addSelect('s.train_type', 'trainType')
|
||||
.addSelect(loadingSource('s'), 'loadingSource')
|
||||
.addSelect(at('s.arrived_at'), 'arrivedAt')
|
||||
.addSelect(at('s.departed_at'), 'departedAt')
|
||||
.addSelect(at('s.unloading_started_at'), 'unloadingStartedAt')
|
||||
.addSelect(at('s.unloading_completed_at'), 'unloadingCompletedAt')
|
||||
.addSelect(at(loadingStart('s')), 'loadingStartedAt')
|
||||
.addSelect(at(loadingEnd('s')), 'loadingCompletedAt')
|
||||
.addSelect(unloadingHours('s'), 'unloadingHours')
|
||||
.addSelect(loadingHours('s'), 'loadingHours')
|
||||
.addSelect(HANDLING_HOURS, 'loadUnloadHours')
|
||||
.addSelect(OTHER_ACTIVITY_HOURS, 'otherActivityHours')
|
||||
.addSelect(STAYING_HOURS, 'stayingHours')
|
||||
.addSelect('s.standard_hours::float8', 'stayStandardHours')
|
||||
.addSelect(
|
||||
`CASE WHEN (${STAYING_HOURS})::numeric <= s.standard_hours
|
||||
THEN 'Encouraging' ELSE 'Needs reason' END`,
|
||||
'stayVerdict',
|
||||
)
|
||||
.addSelect('s.handling_standard_hours::float8', 'handlingStandardHours')
|
||||
.addSelect(
|
||||
cycleRateExpr(`(${HANDLING_HOURS})::numeric`, 's.handling_standard_hours'),
|
||||
'handlingRate',
|
||||
);
|
||||
}
|
||||
|
||||
// Reused verbatim in the GROUP BY, per the trap documented on `periodExpr`.
|
||||
const bucket = periodTruncExprOn('s.arrived_at', params);
|
||||
|
||||
const perStation = byStation(ctx);
|
||||
const qb = stationStaysQb(ctx)
|
||||
.select(periodExprOn('s.arrived_at', params), 'period')
|
||||
.addSelect(perStation ? "'All trains'" : TRAIN_NUMBER, 'trainNumber')
|
||||
.addSelect('s.station', 'station')
|
||||
.addSelect('s.country', 'country')
|
||||
.addSelect('MAX(s.train_type)', 'trainType')
|
||||
.addSelect('COUNT(*)::int', 'stops')
|
||||
.addSelect(`COUNT(${HANDLING_HOURS})::int`, 'handlingMeasured')
|
||||
// Which side of the COALESCE the loading columns came from. A group that
|
||||
// mixes both says so rather than claiming either.
|
||||
.addSelect(
|
||||
`CASE WHEN COUNT(DISTINCT ${loadingSource('s')}) > 1 THEN 'Mixed'
|
||||
return (
|
||||
stationStaysQb(ctx)
|
||||
.select(periodExprOn('s.arrived_at', params), 'period')
|
||||
.addSelect('s.station', 'station')
|
||||
.addSelect('s.country', 'country')
|
||||
.addSelect('COUNT(*)::int', 'stops')
|
||||
.addSelect(`COUNT(${HANDLING_HOURS})::int`, 'handlingMeasured')
|
||||
// Which side of the COALESCE the loading columns came from. A group that
|
||||
// mixes both says so rather than claiming either.
|
||||
.addSelect(
|
||||
`CASE WHEN COUNT(DISTINCT ${loadingSource('s')}) > 1 THEN 'Mixed'
|
||||
ELSE MAX(${loadingSource('s')}) END`,
|
||||
'loadingSource',
|
||||
)
|
||||
.addSelect(avg(unloadingHours('s')), 'avgUnloadingHours')
|
||||
.addSelect(avg(loadingHours('s')), 'avgLoadingHours')
|
||||
.addSelect(avg(HANDLING_HOURS), 'avgLoadUnloadHours')
|
||||
.addSelect(avg(OTHER_ACTIVITY_HOURS), 'avgOtherActivityHours')
|
||||
.addSelect(avg(STAYING_HOURS), 'avgStayingHours')
|
||||
.addSelect('MAX(s.standard_hours)::float8', 'stayStandardHours')
|
||||
.addSelect(
|
||||
`CASE WHEN AVG((${STAYING_HOURS})::numeric) <= MAX(s.standard_hours)
|
||||
'loadingSource',
|
||||
)
|
||||
.addSelect(avg(unloadingHours('s')), 'avgUnloadingHours')
|
||||
.addSelect(avg(loadingHours('s')), 'avgLoadingHours')
|
||||
.addSelect(avg(HANDLING_HOURS), 'avgLoadUnloadHours')
|
||||
.addSelect(avg(OTHER_ACTIVITY_HOURS), 'avgOtherActivityHours')
|
||||
.addSelect(avg(STAYING_HOURS), 'avgStayingHours')
|
||||
.addSelect('MAX(s.standard_hours)::float8', 'stayStandardHours')
|
||||
.addSelect(
|
||||
`CASE WHEN AVG((${STAYING_HOURS})::numeric) <= MAX(s.standard_hours)
|
||||
THEN 'Encouraging' ELSE 'Needs reason' END`,
|
||||
'stayVerdict',
|
||||
)
|
||||
.addSelect(`${HANDLING_STANDARD}::float8`, 'handlingStandardHours')
|
||||
// Same formula the turnaround cycle publishes, so the two read alike.
|
||||
// NULL standard in, NULL rate out — nothing to measure against yet.
|
||||
.addSelect(
|
||||
cycleRateExpr(`AVG((${HANDLING_HOURS})::numeric)`, HANDLING_STANDARD),
|
||||
'handlingRate',
|
||||
)
|
||||
.groupBy(bucket)
|
||||
.addGroupBy('s.station')
|
||||
.addGroupBy('s.country');
|
||||
if (!perStation) qb.addGroupBy(TRAIN_NUMBER);
|
||||
return qb;
|
||||
'stayVerdict',
|
||||
)
|
||||
.addSelect(`${HANDLING_STANDARD}::float8`, 'handlingStandardHours')
|
||||
// Same formula the turnaround cycle publishes, so the two read alike.
|
||||
// NULL standard in, NULL rate out — nothing to measure against yet.
|
||||
.addSelect(
|
||||
cycleRateExpr(`AVG((${HANDLING_HOURS})::numeric)`, HANDLING_STANDARD),
|
||||
'handlingRate',
|
||||
)
|
||||
.groupBy(bucket)
|
||||
.addGroupBy('s.station')
|
||||
.addGroupBy('s.country')
|
||||
);
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await stationStaysQb(ctx)
|
||||
@@ -170,13 +351,26 @@ export const loadingUnloadingReport: ReportDefinition = {
|
||||
.addSelect(`COUNT(${HANDLING_HOURS})::int`, 'measured')
|
||||
.addSelect(avg(HANDLING_HOURS), 'avgHandling')
|
||||
.addSelect(avg(OTHER_ACTIVITY_HOURS), 'avgOther')
|
||||
.getRawOne<{ stops: number; measured: number; avgHandling: number; avgOther: number }>();
|
||||
.getRawOne<{
|
||||
stops: number;
|
||||
measured: number;
|
||||
avgHandling: number;
|
||||
avgOther: number;
|
||||
}>();
|
||||
|
||||
return [
|
||||
{ label: 'Stops measured', value: Number(row?.stops ?? 0) },
|
||||
{ label: 'Handling measured', value: Number(row?.measured ?? 0) },
|
||||
{ label: 'Average loading + unloading', value: Number(row?.avgHandling ?? 0), unit: 'h' },
|
||||
{ label: 'Average other activity', value: Number(row?.avgOther ?? 0), unit: 'h' },
|
||||
{
|
||||
label: 'Average loading + unloading',
|
||||
value: Number(row?.avgHandling ?? 0),
|
||||
unit: 'h',
|
||||
},
|
||||
{
|
||||
label: 'Average other activity',
|
||||
value: Number(row?.avgOther ?? 0),
|
||||
unit: 'h',
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
CARGO_CATEGORIES,
|
||||
CARGO_CATEGORY_EXPR,
|
||||
CARGO_CATEGORY_LABEL_EXPR,
|
||||
REVENUE_CARGO_CATEGORY_EXPR,
|
||||
CONTAINER_CLASSES,
|
||||
CONTAINER_CLASS_EXPR,
|
||||
HANDLING_STANDARD_HOURS_EXPR,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
otherActivityHours,
|
||||
plannedRowsSql,
|
||||
} from './operations-classification';
|
||||
import { REVENUE_CATEGORIES } from './revenue-classification';
|
||||
import { TARGET_DIMENSIONS, TARGET_METRICS } from '../operations-reporting/entities/operations-target.entity';
|
||||
|
||||
/**
|
||||
@@ -69,6 +71,20 @@ describe('operations classification', () => {
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The volume report groups tonnage by this expression and the finance reports
|
||||
* group birr by `REVENUE_CATEGORY_EXPR`. A key only one side can emit is a
|
||||
* bucket that never reconciles — and it fails silently, as a row that simply
|
||||
* has no counterpart.
|
||||
*/
|
||||
it('classifies cargo into keys the revenue vocabulary offers', () => {
|
||||
const offered = new Set(REVENUE_CATEGORIES.map((o) => o.value));
|
||||
const missing = [...new Set(emittedKeys(REVENUE_CARGO_CATEGORY_EXPR))].filter(
|
||||
(k) => !offered.has(k),
|
||||
);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('offers every container class the expression can emit', () => {
|
||||
const offered = new Set(CONTAINER_CLASSES.map((o) => o.value));
|
||||
const missing = [...new Set(emittedKeys(CONTAINER_CLASS_EXPR))].filter((k) => !offered.has(k));
|
||||
|
||||
@@ -11,7 +11,7 @@ import { WagonBookingAllocation } from '../train-schedules/entities/wagon-bookin
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
|
||||
import { ReportContext, ReportFilterDef, ReportFilterOption } from './report.types';
|
||||
import { resolvePeriod, yardOptions } from './revenue-classification';
|
||||
import { REVENUE_CATEGORIES, resolvePeriod, yardOptions } from './revenue-classification';
|
||||
|
||||
/**
|
||||
* The shared vocabulary and SQL behind every operations report — turnaround,
|
||||
@@ -115,6 +115,39 @@ export const CARGO_CATEGORY_EXPR = `CASE
|
||||
ELSE 'UNCLASSIFIED'
|
||||
END`;
|
||||
|
||||
/**
|
||||
* The same cargo, classified into the REVENUE vocabulary — the categories
|
||||
* `revenue-classification.ts` bills against, minus its charge-only buckets
|
||||
* (incidental, first/last mile, customs), which no physical wagon can be.
|
||||
*
|
||||
* Mirrors the cargo arms of `REVENUE_CATEGORY_EXPR` in that expression's own
|
||||
* order, so a ton and the birr charged for it land in the same bucket: empty
|
||||
* re-export before domestic, domestic before anything about what is in the box.
|
||||
* Reports that must reconcile tonnage against revenue group by this one; the
|
||||
* operational vocabulary above keeps sand and bulk apart, which no invoice does.
|
||||
*/
|
||||
export const REVENUE_CARGO_CATEGORY_EXPR = `CASE
|
||||
WHEN ${IS_EMPTY_CONTAINER} THEN 'EMPTY_CONTAINER_REEXPORT'
|
||||
WHEN oy.country IS NOT NULL AND oy.country = dy.country THEN 'DOMESTIC'
|
||||
WHEN ${IS_CONTAINER} AND b.trade_direction = 'EXPORT' THEN 'CONTAINER_EXPORT'
|
||||
WHEN ${IS_CONTAINER} AND ${IS_MULTIMODAL} THEN 'CONTAINER_IMPORT_MULTIMODAL'
|
||||
WHEN ${IS_CONTAINER} THEN 'CONTAINER_IMPORT_UNIMODAL'
|
||||
WHEN ct.code IN (${quote(FERTILIZER_CODES)}) THEN 'FERTILIZER'
|
||||
WHEN ct.code IN (${quote(BREAK_BULK_CODES)}) THEN 'BREAK_BULK'
|
||||
WHEN ct.code IN (${quote(RORO_CODES)}) THEN 'RORO'
|
||||
WHEN b.trade_direction = 'EXPORT' THEN 'OTHER_EXPORT_CARGO'
|
||||
WHEN b.trade_direction = 'IMPORT' THEN 'OTHER_IMPORT_BULK'
|
||||
ELSE 'UNCLASSIFIED'
|
||||
END`;
|
||||
|
||||
/** The revenue vocabulary as a filter, plus the wagon that carries no cargo. */
|
||||
export const REVENUE_CARGO_FILTER: ReportFilterDef = {
|
||||
key: 'categories',
|
||||
label: 'Cargo type',
|
||||
type: 'multiselect',
|
||||
options: [...REVENUE_CATEGORIES, { value: 'EMPTY_WAGON', label: 'Empty wagon' }],
|
||||
};
|
||||
|
||||
export const CONTAINER_CLASS_EXPR = `CASE
|
||||
WHEN ${IS_EMPTY_CONTAINER} THEN 'EMPTY_CONTAINER_RETURN'
|
||||
WHEN b.trade_direction = 'EXPORT' THEN 'CONTAINER_EXPORT'
|
||||
@@ -330,23 +363,13 @@ export const CHARGED_TONS_EXPR = `(
|
||||
* ${stdAgg('charged_tons_per_wagon_general', 70)}
|
||||
)::float8`;
|
||||
|
||||
/** Wagons actually carrying cargo in the grouped set. */
|
||||
export const LOADED_WAGONS_EXPR = 'COUNT(DISTINCT tsw.id)::int';
|
||||
|
||||
/**
|
||||
* Wagons on the departure with nothing allocated to them — the Vehicle-Km base.
|
||||
*
|
||||
* A train-level figure: it belongs to the departure, not to any one cargo type
|
||||
* riding on it, so a report grouped finer than the schedule repeats it rather
|
||||
* than splitting it. Callers that need a total must de-duplicate by schedule.
|
||||
* Wagons actually carrying cargo in the grouped set. The FILTER only bites on a
|
||||
* query built with `includeEmptyWagons` — every row of an allocation-grain
|
||||
* query has an allocation, so it is a no-op there.
|
||||
*/
|
||||
export const SCHEDULE_EMPTY_WAGONS = `(
|
||||
SELECT COUNT(*) FROM freight.train_set_wagons tw
|
||||
WHERE tw.train_set_id = ts.train_set_id AND tw.deleted_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.wagon_booking_allocations a
|
||||
WHERE a.train_set_wagon_id = tw.id AND a.deleted_at IS NULL)
|
||||
)`;
|
||||
export const LOADED_WAGONS_EXPR =
|
||||
'COUNT(DISTINCT tsw.id) FILTER (WHERE wba.id IS NOT NULL)::int';
|
||||
|
||||
/**
|
||||
* Trainsets operated: wagons loaded divided by a full trainset for this cargo.
|
||||
@@ -440,21 +463,41 @@ const DEAD_SCHEDULE_STATUSES = ['DRAFT', 'CANCELLED'];
|
||||
*
|
||||
* The booking is LEFT joined — a wagon can be allocated before its booking data
|
||||
* is complete, and dropping those rows would understate wagon usage.
|
||||
*
|
||||
* `includeEmptyWagons` turns the ledger around to start from the wagon instead:
|
||||
* every wagon of the departure is a row, and one that carried nothing has a
|
||||
* NULL `wba`. Only the volume report wants that — it reports the empty wagons
|
||||
* as their own line — and it costs the other reports a row grain they would
|
||||
* have to filter back out.
|
||||
*/
|
||||
export function allocationLedgerQb(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
export function allocationLedgerQb(
|
||||
ctx: ReportContext,
|
||||
opts: { includeEmptyWagons?: boolean } = {},
|
||||
): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params, directions } = ctx;
|
||||
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(WagonBookingAllocation, 'wba')
|
||||
.innerJoin(TrainSetWagon, 'tsw', 'tsw.id = wba.train_set_wagon_id AND tsw.deleted_at IS NULL')
|
||||
.innerJoin(TrainSchedule, 'ts', 'ts.train_set_id = tsw.train_set_id AND ts.deleted_at IS NULL')
|
||||
const qb = ctx.ds.createQueryBuilder();
|
||||
|
||||
if (opts.includeEmptyWagons) {
|
||||
qb.from(TrainSetWagon, 'tsw')
|
||||
.leftJoin(
|
||||
WagonBookingAllocation,
|
||||
'wba',
|
||||
'wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL',
|
||||
)
|
||||
.where('tsw.deleted_at IS NULL');
|
||||
} else {
|
||||
qb.from(WagonBookingAllocation, 'wba')
|
||||
.innerJoin(TrainSetWagon, 'tsw', 'tsw.id = wba.train_set_wagon_id AND tsw.deleted_at IS NULL')
|
||||
.where('wba.deleted_at IS NULL');
|
||||
}
|
||||
|
||||
qb.innerJoin(TrainSchedule, 'ts', 'ts.train_set_id = tsw.train_set_id AND ts.deleted_at IS NULL')
|
||||
.leftJoin(Booking, 'b', 'b.id = wba.booking_id AND b.deleted_at IS NULL')
|
||||
.leftJoin(CargoType, 'ct', 'ct.id = b.cargo_type_id')
|
||||
.leftJoin(Yard, 'oy', 'oy.id = ts.origin_station_id')
|
||||
.leftJoin(Yard, 'dy', 'dy.id = ts.destination_station_id')
|
||||
.leftJoin(OperationsStandard, 'std', STANDARDS_JOIN)
|
||||
.where('wba.deleted_at IS NULL')
|
||||
.andWhere('ts.status NOT IN (:...deadScheduleStatuses)', {
|
||||
deadScheduleStatuses: DEAD_SCHEDULE_STATUSES,
|
||||
});
|
||||
@@ -715,10 +758,11 @@ export function applyOperationsFilters(
|
||||
export function applyCategoryFilter(
|
||||
qb: SelectQueryBuilder<ObjectLiteral>,
|
||||
params: Record<string, unknown>,
|
||||
categoryExpr: string = CARGO_CATEGORY_EXPR,
|
||||
): void {
|
||||
const categories = params.categories as string[] | null;
|
||||
if (categories?.length) {
|
||||
qb.andWhere(`${CARGO_CATEGORY_EXPR} IN (:...categories)`, { categories });
|
||||
qb.andWhere(`${categoryExpr} IN (:...categories)`, { categories });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||