Merge branch 'dev' of https://github.com/Tria-plc/edr-platform into feature/group-booking

This commit is contained in:
Roba Boru
2026-08-27 22:55:42 +03:00
212 changed files with 10522 additions and 1589 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 673 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);

View File

@@ -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 });

View File

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

View File

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

View File

@@ -0,0 +1,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();
});
});

View File

@@ -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;
}

View File

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

View File

@@ -7,7 +7,7 @@ import {
Logger,
NotFoundException,
} from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
import { ExchangeService } from '@edr/api-common';
import { Freight, NotificationAudience, NotificationType } from '@edr/types';
import { DataSource, EntityManager, In, IsNull } from 'typeorm';
@@ -126,6 +126,7 @@ export class BookingWagonCancellationService {
@Inject(forwardRef(() => FirstMileService))
private readonly firstMile: FirstMileService,
private readonly inbox: NotificationInboxService,
private readonly events: EventEmitter2,
) {}
// ── T1: request ────────────────────────────────────────────────────────────
@@ -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) {

View File

@@ -725,6 +725,30 @@ export class BookingsController {
return this.wagonCancellationService.withdraw(cancellationId);
}
@Get("wagon-cancellations/:cancellationId/rebook-partners")
@ApiOperation({
summary:
"Consolidation partner candidates for rebooking an odd-20ft credit on the given day (GL picks who shares the rebooked wagon)",
})
async listRebookPartners(
@Param("cancellationId", ParseUUIDPipe) cancellationId: string,
@Query("scheduledDate") scheduledDate: string,
@CurrentUser() user: TCurrentUser,
) {
await this.assertWagonCancellationActor(
cancellationId,
user,
FREIGHT_PERMS.bookings.wagonCancellationRebook,
);
if (!scheduledDate) {
throw new BadRequestException("scheduledDate is required.");
}
return this.wagonCancellationService.rebookPartnerCandidates(
cancellationId,
scheduledDate,
);
}
@Post("wagon-cancellations/:cancellationId/rebook")
@ApiOperation({
summary:

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}

View File

@@ -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" } });

View File

@@ -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();
});

View File

@@ -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([]);
});
});

View File

@@ -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

View File

@@ -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,

View File

@@ -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[];
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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(

View File

@@ -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 || "",
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,

View File

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

View File

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

View File

@@ -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

View File

@@ -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) },
];
},

View File

@@ -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 legs 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 corridors tonnage and its revenue read in the same buckets; wagons ' +
'that carried nothing are their own “Empty wagon” line. Volumes and wagon counts ' +
'belong to the train, not to the leg, so they repeat on every leg it ran rather than ' +
'being split between them — the KPIs above count each train once. Ton/Km and ' +
'Vehicle-Km are the exception and are the legs own, so they add up across legs into ' +
'the real corridor figure.',
group: 'Operations',
filters: [...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER],
filters: [...OPERATIONS_FILTERS, REVENUE_CARGO_FILTER],
columns: [
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' },
{ key: 'departedAt', label: 'Departure', type: 'date', sortable: true, sortExpr: 'ts.scheduled_departure_date' },
{ key: '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)

View File

@@ -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),
},
];
},
};

View File

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

View File

@@ -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 trains 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 stops own ' +
'durations. Grouped by Station it is one row per station per period, averaged over every ' +
'train that called there, the way the OCC report publishes it. Total loading and unloading is ' +
'the stops handling window, unloading start to loading end, which is the container ' +
'measure; the unloading and loading columns split it for bulk stations that only do ' +
'one of the two (Nagad, BCC and DMP on the Djibouti side; Sebeta, GMP, Adama and Modjo ' +
@@ -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',
},
];
},
};

View File

@@ -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));

View File

@@ -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 });
}
}

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