diff --git a/.portal-flows-assets/00-invoice-detail-pay.png b/.portal-flows-assets/00-invoice-detail-pay.png new file mode 100644 index 000000000..c57f4b977 Binary files /dev/null and b/.portal-flows-assets/00-invoice-detail-pay.png differ diff --git a/.portal-flows-assets/01-invoices-due-full.png b/.portal-flows-assets/01-invoices-due-full.png new file mode 100644 index 000000000..bea5dc558 Binary files /dev/null and b/.portal-flows-assets/01-invoices-due-full.png differ diff --git a/.portal-flows-assets/02-login-full.png b/.portal-flows-assets/02-login-full.png new file mode 100644 index 000000000..f0820e0a8 Binary files /dev/null and b/.portal-flows-assets/02-login-full.png differ diff --git a/.portal-flows-assets/03-hager-invoices-empty-full.png b/.portal-flows-assets/03-hager-invoices-empty-full.png new file mode 100644 index 000000000..2b56a9fcd Binary files /dev/null and b/.portal-flows-assets/03-hager-invoices-empty-full.png differ diff --git a/.portal-flows-assets/04-contracts-list-full.png b/.portal-flows-assets/04-contracts-list-full.png new file mode 100644 index 000000000..99763e1b2 Binary files /dev/null and b/.portal-flows-assets/04-contracts-list-full.png differ diff --git a/.portal-flows-assets/05-home-full.png b/.portal-flows-assets/05-home-full.png new file mode 100644 index 000000000..3eb3d0d6c Binary files /dev/null and b/.portal-flows-assets/05-home-full.png differ diff --git a/.portal-flows-assets/06-contract-detail-signed-full.png b/.portal-flows-assets/06-contract-detail-signed-full.png new file mode 100644 index 000000000..801016e19 Binary files /dev/null and b/.portal-flows-assets/06-contract-detail-signed-full.png differ diff --git a/.portal-flows-assets/07-contract-step1-full.png b/.portal-flows-assets/07-contract-step1-full.png new file mode 100644 index 000000000..43b2fe8b0 Binary files /dev/null and b/.portal-flows-assets/07-contract-step1-full.png differ diff --git a/.portal-flows-assets/08-contract-step1-filled-full.png b/.portal-flows-assets/08-contract-step1-filled-full.png new file mode 100644 index 000000000..02cbf4d03 Binary files /dev/null and b/.portal-flows-assets/08-contract-step1-filled-full.png differ diff --git a/.portal-flows-assets/09-contract-step2-full.png b/.portal-flows-assets/09-contract-step2-full.png new file mode 100644 index 000000000..193fc5252 Binary files /dev/null and b/.portal-flows-assets/09-contract-step2-full.png differ diff --git a/.portal-flows-assets/10-contract-step3-review-full.png b/.portal-flows-assets/10-contract-step3-review-full.png new file mode 100644 index 000000000..5bbd72746 Binary files /dev/null and b/.portal-flows-assets/10-contract-step3-review-full.png differ diff --git a/.portal-flows-assets/11-contract-duplicate-blocked-full.png b/.portal-flows-assets/11-contract-duplicate-blocked-full.png new file mode 100644 index 000000000..c93a06db1 Binary files /dev/null and b/.portal-flows-assets/11-contract-duplicate-blocked-full.png differ diff --git a/.portal-flows-assets/11b-quotation-approve-full.png b/.portal-flows-assets/11b-quotation-approve-full.png new file mode 100644 index 000000000..f381f4988 Binary files /dev/null and b/.portal-flows-assets/11b-quotation-approve-full.png differ diff --git a/.portal-flows-assets/13-bookings-list-full.png b/.portal-flows-assets/13-bookings-list-full.png new file mode 100644 index 000000000..8477b2a76 Binary files /dev/null and b/.portal-flows-assets/13-bookings-list-full.png differ diff --git a/.portal-flows-assets/14-booking-documents-modal-full.png b/.portal-flows-assets/14-booking-documents-modal-full.png new file mode 100644 index 000000000..b58f9b919 Binary files /dev/null and b/.portal-flows-assets/14-booking-documents-modal-full.png differ diff --git a/.portal-flows-assets/15-nati-contracts-list-full.png b/.portal-flows-assets/15-nati-contracts-list-full.png new file mode 100644 index 000000000..191f75f81 Binary files /dev/null and b/.portal-flows-assets/15-nati-contracts-list-full.png differ diff --git a/.portal-flows-assets/16-nati-contracts-list-actions.png b/.portal-flows-assets/16-nati-contracts-list-actions.png new file mode 100644 index 000000000..d7fd73df7 Binary files /dev/null and b/.portal-flows-assets/16-nati-contracts-list-actions.png differ diff --git a/.portal-flows-assets/17-initiate-booking-confirm.png b/.portal-flows-assets/17-initiate-booking-confirm.png new file mode 100644 index 000000000..16b0b481d Binary files /dev/null and b/.portal-flows-assets/17-initiate-booking-confirm.png differ diff --git a/.portal-flows-assets/18-nati-bookings-list-actions.png b/.portal-flows-assets/18-nati-bookings-list-actions.png new file mode 100644 index 000000000..f4446db24 Binary files /dev/null and b/.portal-flows-assets/18-nati-bookings-list-actions.png differ diff --git a/.portal-flows-assets/19-complete-booking-cargo-full.png b/.portal-flows-assets/19-complete-booking-cargo-full.png new file mode 100644 index 000000000..1cb7c216a Binary files /dev/null and b/.portal-flows-assets/19-complete-booking-cargo-full.png differ diff --git a/.portal-flows-assets/portal-flows.html b/.portal-flows-assets/portal-flows.html new file mode 100644 index 000000000..2bbd40681 --- /dev/null +++ b/.portal-flows-assets/portal-flows.html @@ -0,0 +1,312 @@ + + + + +EDR Freight Portal — Priority Flows + + + + +
+
EDR Freight — Priority Flows
+

Portal Side
Contract Signing · Booking · Payment

+

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.

+
+
Driven live against the shared dev environment (edr_dev)
+
Personas: hager@gmail.com (Hagernesh Tadesse) for contract & booking; nati@gmail.com (NAti Wondish) for payment
+
Companion document to EDR-Freight-Priority-Flows.pdf (backoffice/staff side)
+
+
+ +
+

Contents

+
    +
  1. — 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.
  2. +
  3. — the Bookings list and the document-review flow a customer works through on an existing booking.
  4. +
  5. — 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.
  6. +
+
+ +
+

1 · Contract creation & signing

+

Portal customer hager@gmail.com, logged into the freight portal (localhost:5273).

+ +
+
1
Sign in
+
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).
+ +
+ +
+
2
Home
+
Landing page after login: quick stats and shortcuts into Contracts, Bookings, and Invoices.
+ +
+ +
+
3
Contracts list
+
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.
+ +
+ +
+
4
New Contract — Step 1: Setup
+
Operation Type, Contract Kind (One-Time vs. Framework), New-vs-Renewal, and the Service Type cards — the entry point of the wizard.
+ +
+ +
+
5
Step 1 filled — Operation Type + Service
+
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).
+ +
+ +
+
6
Step 2: Cargo & Route
+
Cargo scope (containerised, both 20ft/40ft), and the origin/destination yard pair that defines the route.
+ +
+ +
+
7
Step 3: Review & Submit
+
Final review of the assembled contract terms — operation, service, route, cargo scope, and customs handling — before submitting.
+ +
+ +
+
8
Approve your quotation
+
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.
+ +
+ +
+
9
Real validation: duplicate contract blocked
+
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.
+ +
+ +
+ Once EDR approves the contract, the customer signs it + A submitted contract moves through EDR review as: Submitted → Pending Approval → Approved → + Approved Pending Signature → Contract Ready. The "View & sign contract" button only + appears once the contract reaches Contract Ready — it shows on the Contracts list row and + on the contract's own detail page, and opens a dedicated preview-and-sign page + (/contracts/:id/view), not a plain button on the detail page. +
    +
  1. EDR staff approve the contract and price it.
  2. +
  3. The customer is notified the moment it's ready to sign — SMS, email, and an in-app + notification all fire together (per the customer's description of the real notification + flow — not re-verified against the notification-sending code for this document).
  4. +
  5. The customer opens the notification or the Contracts list, clicks "View & sign + contract", scrolls the full contract text, and ticks the consent checkbox.
  6. +
  7. 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.
  8. +
  9. Status moves to Signed — Awaiting Staff, then Fully Executed once EDR counter-signs.
  10. +
+ 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. +
+ +
+
11
Where "View & sign" actually shows (a different customer's contract list)
+
A second real account (nati@gmail.com, 17 contracts) confirms the button live: its Action column carries "View", "Request shipment", "Book shipment", and "Initiate booking" depending on each row's status — the same column shows "View & sign" the moment a row reaches Contract Ready.
+ +
+ +
+ The signable window is real and it closes + This account's own CTR-2026-00089 was captured with a live "View & sign" button two days ago + (per the customer's own screenshot) — by the time this document was rebuilt, that same contract + had already moved to Expired 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. +
+ +
+
10
Fully executed contract — signatures
+
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.
+ +
+
+ +
+

2 · Booking

+

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.

+ +
+
1
Initiate booking — confirmation
+
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.
+ +
+ +
+
2
Complete Your Booking — cargo details
+
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 "Enter your cargo details first — available shipment days depend on the wagons your cargo needs," 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.
+ +
+ +
+
3
Bookings list — one contract's view
+
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.
+ +
+ +
+
4
Import documents dialog
+
Clicking "Review documents" opens this in place, no page navigation. All four required import documents — Bill of Lading/Waybill, Packing List, Import declaration, and Ethiopia T1 & Djibouti T1 — show status "Under review," each with View/Download and a "Replace file" drop zone. The banner is explicit: "Only re-upload the documents flagged with a query below — approved documents stay as they are." "Submit documents" stays disabled while nothing is flagged.
+ +
+ +
+
5
Bookings list — the fuller picture (nati@gmail.com, 38 bookings)
+
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).
+ +
+
+ +
+

3 · Payment

+

The customer-facing payment surface is the Invoices page.

+ +
+
1
Invoices — hager@gmail.com (nothing to pay)
+
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.
+ +
+ +
+
2
Invoices — nati@gmail.com, filtered to Due
+
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.
+ +
+ +
+
3
Invoice detail — the Pay screen
+
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.
+ +
+ +
+ What "Pay" opens (from the customer's own screenshot) + Clicking "Pay" opens a "Complete your payment" dialog: the amount due, a red warning — + "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" — a payment-method + picker (the option shown was CBE Bill Payment — "Pay at any CBE branch, app or USSD · ETB"), + 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. +
+ +
+ Paying by bank transfer — step by step (customer-provided process) +
    +
  1. Open your internet banking app or account.
  2. +
  3. Copy the PNR code EDR sent you by SMS and email.
  4. +
  5. In your bank's payment menu, choose Travel, then Land Transport, then + EDR Freight as the biller.
  6. +
  7. Paste the PNR code into the reference/biller-code field.
  8. +
  9. Confirm the amount shown matches the invoice total, then pay.
  10. +
+ 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. +
+ +
+ Stopped short of submitting the payment — three separate attempts, all blocked + 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). +
+
+ + + diff --git a/.portal-flows-assets/render.mjs b/.portal-flows-assets/render.mjs new file mode 100644 index 000000000..4b0780043 --- /dev/null +++ b/.portal-flows-assets/render.mjs @@ -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'); diff --git a/EDR-Freight-Priority-Flows-Portal.pdf b/EDR-Freight-Priority-Flows-Portal.pdf index fe37cc9e8..8d8115757 100644 Binary files a/EDR-Freight-Priority-Flows-Portal.pdf and b/EDR-Freight-Priority-Flows-Portal.pdf differ diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 68bbdcba4..ccff74387 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -1,5 +1,5 @@ import { applyDecorators, UseGuards } from '@nestjs/common'; -import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { FreightJwtGuard } from './freight-jwt.guard'; import { FreightPermissionGuard, @@ -11,7 +11,7 @@ import { FREIGHT_PERMS } from '../seed/freight-permissions.registry'; export const BookingStaff = (permission: string | string[]) => applyDecorators( UseGuards( - JwtGuard, + FreightJwtGuard, FreightPermissionGuard( Array.isArray(permission) ? permission : [permission], ), @@ -26,11 +26,11 @@ export const BookingStaff = (permission: string | string[]) => * BookingStaff() or MixedAudience(); kept for routes not yet swept. */ export const StaffReference = () => - applyDecorators(UseGuards(JwtGuard, FreightPermissionGuard([]))); + applyDecorators(UseGuards(FreightJwtGuard, FreightPermissionGuard([]))); /** Portal routes: customer accounts only; ownership scoping stays in services. */ export const PortalCustomer = () => - applyDecorators(UseGuards(JwtGuard, PortalCustomerGuard)); + applyDecorators(UseGuards(FreightJwtGuard, PortalCustomerGuard)); /** * Routes both audiences call (sign, shared document reads, handover): staff @@ -40,7 +40,7 @@ export const PortalCustomer = () => export const MixedAudience = (permission: string | string[]) => applyDecorators( UseGuards( - JwtGuard, + FreightJwtGuard, MixedAudienceGuard( Array.isArray(permission) ? permission : [permission], ), @@ -95,6 +95,24 @@ export const TrainSchedulingLoad = () => export const TrainSchedulingUnload = () => BookingStaff(FREIGHT_PERMS.trainScheduling.unload); +/** + * Per-station loading/unloading time windows — the four buttons are four + * permissions so start and end can be granted to different people. The same + * endpoint that records a click also edits it (explicit `at`), so each + * permission covers editing its own timestamp too. + */ +export const TrainSchedulingLoadingStart = () => + BookingStaff(FREIGHT_PERMS.trainScheduling.loadingStart); + +export const TrainSchedulingLoadingEnd = () => + BookingStaff(FREIGHT_PERMS.trainScheduling.loadingEnd); + +export const TrainSchedulingUnloadingStart = () => + BookingStaff(FREIGHT_PERMS.trainScheduling.unloadingStart); + +export const TrainSchedulingUnloadingEnd = () => + BookingStaff(FREIGHT_PERMS.trainScheduling.unloadingEnd); + export const TrainSchedulingCancel = () => BookingStaff(FREIGHT_PERMS.trainScheduling.cancel); diff --git a/apps/edr-freight-api/src/common/freight-jwt.guard.ts b/apps/edr-freight-api/src/common/freight-jwt.guard.ts new file mode 100644 index 000000000..68b842217 --- /dev/null +++ b/apps/edr-freight-api/src/common/freight-jwt.guard.ts @@ -0,0 +1,101 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { DataSource } from 'typeorm'; + +/** One position as the login snapshot stores it (`iam.sessions.userInfo`). */ +type SnapshotPosition = { id?: string; [key: string]: unknown }; + +type SessionUserInfo = { + employee?: { id?: string; positions?: SnapshotPosition[] }[]; +}; + +/** + * Like the IAM JwtGuard, but keeps the caller's SECONDARY positions. + * + * IAM models an employee as holding many positions, and the login snapshot in + * `iam.sessions.userInfo` carries all of them. `JwtGuard.parseToken` then + * collapses that to a single `employee.position` — whichever the request + * headers select, else `positions[0]` — and drops the rest. Non-delegate + * secondary positions vanish entirely, so staff holding two posts resolve to + * only one post's permissions and every check on the other one rejects them. + * + * This re-attaches the full list as `employee.positions`. `employee.position` + * is left exactly as the parent set it, so everything reading the single + * position today (audit log, delegation deadline) is unaffected; only the + * permission utils, which prefer the array, see the difference. + */ +@Injectable() +export class FreightJwtGuard extends IamJwtGuard implements CanActivate { + // ponytail: unbounded-until-TTL map, cleared wholesale when it gets big. + // Sessions are few and the value is small; swap for an LRU if that changes. + private static readonly CACHE_TTL_MS = 30_000; + private static readonly CACHE_MAX_ENTRIES = 5_000; + private readonly cache = new Map< + string, + { positions: SnapshotPosition[]; expiresAt: number } + >(); + + constructor( + reflector: Reflector, + @InjectDataSource() private readonly ds: DataSource, + ) { + super(reflector, ds); + } + + async canActivate(context: ExecutionContext): Promise { + if (!(await super.canActivate(context))) return false; + + const user = context.switchToHttp().getRequest().user as + | TCurrentUser + | undefined; + const employee = user?.employee; + if (!employee || !user?.sessionId) return true; + + const positions = await this.positionsForSession( + user.sessionId, + employee.id, + ); + // Never blank out what the parent resolved: an unreadable session or a + // snapshot without positions must degrade to the single-position + // behaviour, not to no positions at all. + if (positions.length) { + (employee as { positions?: SnapshotPosition[] }).positions = positions; + } + return true; + } + + /** Every position the login snapshot holds for this employee. */ + private async positionsForSession( + sessionId: string, + employeeId: string | undefined, + ): Promise { + const now = Date.now(); + const hit = this.cache.get(sessionId); + if (hit && hit.expiresAt > now) return hit.positions; + + let positions: SnapshotPosition[] = []; + try { + const rows: { userInfo: SessionUserInfo | null }[] = await this.ds.query( + `SELECT "userInfo" FROM iam.sessions WHERE id = $1`, + [sessionId], + ); + const employees = rows[0]?.userInfo?.employee ?? []; + const match = + employees.find((e) => e?.id && e.id === employeeId) ?? employees[0]; + positions = match?.positions ?? []; + } catch { + return []; // iam unreachable — caller keeps the parent's single position + } + + if (this.cache.size >= FreightJwtGuard.CACHE_MAX_ENTRIES) + this.cache.clear(); + this.cache.set(sessionId, { + positions, + expiresAt: now + FreightJwtGuard.CACHE_TTL_MS, + }); + return positions; + } +} diff --git a/apps/edr-freight-api/src/common/freight-permission.util.spec.ts b/apps/edr-freight-api/src/common/freight-permission.util.spec.ts index f3931f78a..562b21156 100644 --- a/apps/edr-freight-api/src/common/freight-permission.util.spec.ts +++ b/apps/edr-freight-api/src/common/freight-permission.util.spec.ts @@ -2,6 +2,7 @@ import { assertCanApproveContractStep, canEditContractStep, collectPermissionKeys, + collectPositionTypeKeys, hasFreightPermission, setPositionTypePermissionResolver, } from './freight-permission.util'; @@ -121,3 +122,66 @@ describe('collectPermissionKeys — position-type grants', () => { expect(hasFreightPermission(direct, CLEARANCE)).toBe(true); }); }); + +/** + * IAM lets an employee hold several positions, but the vendored `JwtGuard` + * collapses `employee.positions[]` down to a single `employee.position` and + * drops the rest — so staff on two posts resolved to one post's permissions + * and every check on the other rejected them. `FreightJwtGuard` restores the + * full list as `employee.positions`; these cover the union that depends on it. + */ +describe('multiple positions', () => { + // Shaped like the real two-post employee: GL chief AND GL director. + const twoPost = { + employee: { + // What the vendored guard leaves behind — one of the two, arbitrarily. + position: { + positionType: { key: 'djibouti-gl-chief' }, + permissions: [{ key: FREIGHT_PERMS.contracts.view }], + }, + // What FreightJwtGuard puts back. + positions: [ + { + positionType: { key: 'djibouti-gl-chief' }, + permissions: [{ key: FREIGHT_PERMS.contracts.view }], + }, + { + positionType: { key: 'djibouti-gl-director' }, + permissions: [{ key: FREIGHT_PERMS.bookings.view }], + }, + ], + }, + }; + + it('unions permissions across every position', () => { + const keys = collectPermissionKeys(twoPost); + expect(keys).toContain(FREIGHT_PERMS.contracts.view); + expect(keys).toContain(FREIGHT_PERMS.bookings.view); + }); + + it('grants the secondary position’s permission, not just the first', () => { + expect(hasFreightPermission(twoPost, FREIGHT_PERMS.bookings.view)).toBe(true); + }); + + it('answers to both position types', () => { + expect(collectPositionTypeKeys(twoPost)).toEqual( + expect.arrayContaining(['djibouti-gl-chief', 'djibouti-gl-director']), + ); + }); + + it('does not double-count the position the guard also left singular', () => { + const keys = collectPermissionKeys(twoPost); + expect(keys.filter((k) => k === FREIGHT_PERMS.contracts.view)).toHaveLength(1); + }); + + it('still resolves the single position when the array is absent', () => { + // A request that skipped FreightJwtGuard must degrade to the old behaviour, + // not to no permissions at all. + const onePost = { + employee: { + position: { permissions: [{ key: FREIGHT_PERMS.contracts.view }] }, + }, + }; + expect(hasFreightPermission(onePost, FREIGHT_PERMS.contracts.view)).toBe(true); + }); +}); diff --git a/apps/edr-freight-api/src/common/freight-permission.util.ts b/apps/edr-freight-api/src/common/freight-permission.util.ts index bc98a6df4..1078c5ad9 100644 --- a/apps/edr-freight-api/src/common/freight-permission.util.ts +++ b/apps/edr-freight-api/src/common/freight-permission.util.ts @@ -17,6 +17,15 @@ type MeLikeUser = { permissions?: PermissionLike[]; positionType?: PositionTypeLike | null; }; + /** + * Every position the employee holds, restored by `FreightJwtGuard` + * from the login snapshot. The IAM guard only ever sets the singular + * `position` above; without this, a second post's grants are invisible. + */ + positions?: { + permissions?: PermissionLike[]; + positionType?: PositionTypeLike | null; + }[]; delegatedPositions?: { permissions?: PermissionLike[] }[]; } | { @@ -98,10 +107,15 @@ export function collectPermissionKeys(user: MeLikeUser | null | undefined): stri return [...keys]; } - for (const p of employee.position?.permissions ?? []) { - if (p.key) keys.add(p.key); + // `position` is whichever single post the IAM guard selected; `positions` is + // the full set FreightJwtGuard restores. Walk both — the array is absent on + // a session the guard could not re-read, and the two overlap harmlessly. + for (const pos of [employee.position, ...(employee.positions ?? [])]) { + for (const p of pos?.permissions ?? []) { + if (p.key) keys.add(p.key); + } + addTypePermissions(pos?.positionType); } - addTypePermissions(employee.position?.positionType); for (const delegated of employee.delegatedPositions ?? []) { for (const p of delegated.permissions ?? []) { if (p.key) keys.add(p.key); @@ -158,8 +172,10 @@ export function collectPositionTypeKeys( return [...keys]; } - if (employee.position?.positionType?.key) { - keys.add(employee.position.positionType.key); + // Both shapes, same reason as collectPermissionKeys: an employee holding two + // posts answers to both their position types. + for (const pos of [employee.position, ...(employee.positions ?? [])]) { + if (pos?.positionType?.key) keys.add(pos.positionType.key); } return [...keys]; } diff --git a/apps/edr-freight-api/src/common/rule-engine-guards.ts b/apps/edr-freight-api/src/common/rule-engine-guards.ts index ba8096b5b..d7297d3b5 100644 --- a/apps/edr-freight-api/src/common/rule-engine-guards.ts +++ b/apps/edr-freight-api/src/common/rule-engine-guards.ts @@ -1,5 +1,5 @@ import { applyDecorators, UseGuards } from '@nestjs/common'; -import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { FreightJwtGuard } from './freight-jwt.guard'; import { FreightPermissionGuard } from './freight-permission.guard'; import { @@ -10,7 +10,7 @@ import { export const RuleEngineView = (slug: RuleEngineResourceSlug) => applyDecorators( - UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.view(slug)])), + UseGuards(FreightJwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.view(slug)])), ); // Granular CRUD replaces the retired coarse RuleEngineManage. Each write @@ -18,17 +18,17 @@ export const RuleEngineView = (slug: RuleEngineResourceSlug) => // update on PATCH / reorder / move-order, delete on DELETE. export const RuleEngineCreate = (slug: RuleEngineResourceSlug) => applyDecorators( - UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.create(slug)])), + UseGuards(FreightJwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.create(slug)])), ); export const RuleEngineUpdate = (slug: RuleEngineResourceSlug) => applyDecorators( - UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.update(slug)])), + UseGuards(FreightJwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.update(slug)])), ); export const RuleEngineDelete = (slug: RuleEngineResourceSlug) => applyDecorators( - UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.delete(slug)])), + UseGuards(FreightJwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.delete(slug)])), ); /** @@ -38,5 +38,5 @@ export const RuleEngineDelete = (slug: RuleEngineResourceSlug) => */ export const RuleEngineApprove = (slug: RuleEngineApprovableSlug) => applyDecorators( - UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.approve(slug)])), + UseGuards(FreightJwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.approve(slug)])), ); diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index 36e4e34d0..6a64f6199 100644 --- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -122,6 +122,8 @@ export class ContractDocumentViewModelBuilder { contract.customsClearingEnabled, // Bulk templates are keyed by the contract's cargo type. (contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId, + // Ethiopian-customs-only service types resolve to the Ethiopian variant. + contract.serviceType?.includesEthiopianCustomsOnly, ); dynamicTemplate = dynamicSource ? { diff --git a/apps/edr-freight-api/src/migrations/3690000000000-AuditLogReference.ts b/apps/edr-freight-api/src/migrations/3690000000000-AuditLogReference.ts new file mode 100644 index 000000000..499944a05 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3690000000000-AuditLogReference.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds `reference` to freight.audit_logs — the human identifier of the entity + * the action touched (booking reference, schedule number, train number, …), + * resolved at write time by the audit interceptor. `resource_id` stays the + * machine id; this column is what staff actually type into the search box. + * + * Production safety: + * - `ADD COLUMN ... NOT NULL DEFAULT ''` is metadata-only on Postgres 11+: + * no table rewrite, no long lock, existing rows read '' without being + * touched. Rows written before this migration keep '' permanently — + * capture starts from deploy, by design (no backfill). + * - Everything is IF NOT EXISTS so a hand-patched database converges + * instead of failing the deploy. + * - No existing column is altered and nothing is dropped: zero data-loss + * surface. + * + * The index is an expression index on upper(reference) with + * text_pattern_ops so the search endpoint's case-insensitive prefix match + * (`upper(reference) LIKE upper($1) || '%'`) is indexed. '' rows are + * excluded to keep it small — they are never searched for. + */ +export class AuditLogReference3690000000000 implements MigrationInterface { + name = 'AuditLogReference3690000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.audit_logs + ADD COLUMN IF NOT EXISTS reference varchar(64) NOT NULL DEFAULT '' + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_audit_logs_reference_upper + ON freight.audit_logs (upper(reference) text_pattern_ops) + WHERE reference <> '' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Down discards every captured reference — acceptable only because down + // migrations are never run against production here. + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_audit_logs_reference_upper`); + await queryRunner.query(`ALTER TABLE freight.audit_logs DROP COLUMN IF EXISTS reference`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3710000000000-BookingClearingAgentContact.ts b/apps/edr-freight-api/src/migrations/3710000000000-BookingClearingAgentContact.ts new file mode 100644 index 000000000..0590d6395 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3710000000000-BookingClearingAgentContact.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds the customs clearing agent's contact details to freight.bookings. + * + * The agent moved from the contract to the booking: on a without-customs + * service the customer now names their agent (name, email, phone) when + * completing each booking, instead of once at contract creation. The existing + * `customs_clearing_agent` column keeps the name; these two columns add the + * contact info. Nullable — customs-bundled and legacy bookings have none. + */ +export class BookingClearingAgentContact3710000000000 implements MigrationInterface { + name = 'BookingClearingAgentContact3710000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS customs_clearing_agent_email varchar(200), + ADD COLUMN IF NOT EXISTS customs_clearing_agent_phone varchar(50) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS customs_clearing_agent_email, + DROP COLUMN IF EXISTS customs_clearing_agent_phone + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3720000000000-ScheduleStationWorkLogs.ts b/apps/edr-freight-api/src/migrations/3720000000000-ScheduleStationWorkLogs.ts new file mode 100644 index 000000000..6998895c2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3720000000000-ScheduleStationWorkLogs.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-station loading/unloading time windows on a schedule, operator-clicked: + * { [yardId]: { loading?: { startedAt, endedAt, startedByUserId, endedByUserId }, + * unloading?: { same } } } + * Booking load/unload is gated on the matching window having been started. + */ +export class ScheduleStationWorkLogs3720000000000 implements MigrationInterface { + name = 'ScheduleStationWorkLogs3720000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS station_work_logs jsonb + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS station_work_logs + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3730000000000-WagonDetachRequests.ts b/apps/edr-freight-api/src/migrations/3730000000000-WagonDetachRequests.ts new file mode 100644 index 000000000..1c7fcb3db --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3730000000000-WagonDetachRequests.ts @@ -0,0 +1,74 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Approval gate for detaching a wagon (or sending it to maintenance) from a + * train whose run is already SCHEDULED. + * + * Before scheduling, the consist is the builder's to edit. After scheduling, + * pulling a wagon changes a departure customers booked against, so it becomes + * a two-person action: one staffer files a request with a reason, another + * staffer (with trains:approve_wagon_detach) approves it — approval executes + * the detach on the spot. Rows are never deleted; decided rows are the audit + * trail of who asked, who decided, and why. + * + * One PENDING row per (train, wagon) at a time — a second request while one is + * undecided is a coordination failure, not a workflow (partial unique index). + */ +export class WagonDetachRequests3730000000000 implements MigrationInterface { + name = 'WagonDetachRequests3730000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ BEGIN + CREATE TYPE freight.wagon_detach_requests_status_enum + AS ENUM ('PENDING', 'APPROVED', 'REJECTED'); + EXCEPTION WHEN duplicate_object THEN NULL; END $$ + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.wagon_detach_requests ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + train_id uuid NOT NULL REFERENCES freight.trains (id), + wagon_id uuid NOT NULL REFERENCES freight.wagons (id), + -- Snapshot: the audit trail must still read correctly after the wagon + -- is renumbered or deleted. + wagon_number varchar(50) NOT NULL, + action varchar(20) NOT NULL, + reason varchar(500) NOT NULL, + status freight.wagon_detach_requests_status_enum NOT NULL DEFAULT 'PENDING', + -- Who asked and who decided. Both recorded: the point of the gate is + -- that they are different people. + requested_by uuid, + decided_by uuid, + decided_at timestamptz, + decision_note varchar(500), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagon_detach_requests_train + ON freight.wagon_detach_requests (train_id) + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagon_detach_requests_train_status + ON freight.wagon_detach_requests (train_id, status) + `); + + // The workflow invariant, enforced where it cannot race: at most one + // undecided request per wagon per train. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_wagon_detach_requests_one_pending + ON freight.wagon_detach_requests (train_id, wagon_id) + WHERE status = 'PENDING' AND deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_detach_requests`); + await queryRunner.query(`DROP TYPE IF EXISTS freight.wagon_detach_requests_status_enum`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3740000000000-BookingBulkRequestedWagons.ts b/apps/edr-freight-api/src/migrations/3740000000000-BookingBulkRequestedWagons.ts new file mode 100644 index 000000000..64c9ac2c7 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3740000000000-BookingBulkRequestedWagons.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * NUMBER_OF_WAGONS cargo unit: the customer books a wagon COUNT alongside the + * bulk weight. `bulk_requested_wagons` drives allocation and PER_WAGON pricing; + * `bulk_item_count` is the optional informational item count entered with it. + * Nullable — every other cargo unit leaves both empty. + */ +export class BookingBulkRequestedWagons3740000000000 implements MigrationInterface { + name = 'BookingBulkRequestedWagons3740000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS bulk_requested_wagons int, + ADD COLUMN IF NOT EXISTS bulk_item_count int + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS bulk_requested_wagons, + DROP COLUMN IF EXISTS bulk_item_count + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3750000000000-EthiopianCustomsContractTemplates.ts b/apps/edr-freight-api/src/migrations/3750000000000-EthiopianCustomsContractTemplates.ts new file mode 100644 index 000000000..564972333 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3750000000000-EthiopianCustomsContractTemplates.ts @@ -0,0 +1,118 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults'; + +/** + * Third customs-clearing option on contract templates: Ethiopian-customs-only + * (the Service Provider clears the Ethiopian side only, Djibouti stays with + * the Client), matching service types with includes_ethiopian_customs_only. + * + * - ethiopian_customs_only column on contract_templates (bulk variant flag; + * the seeded container variants carry it in the code suffix instead, like + * the existing _CUSTOMS/_NO_CUSTOMS pair). + * - The bulk unique index and intercity check widen to the new flag. + * - Seeds the two new system container templates from the defaults pack. + */ +const SEEDED_CODES = [ + 'IMPORT_CONTAINER_ETHIOPIAN_CUSTOMS', + 'EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS', +] as const; + +export class EthiopianCustomsContractTemplates3750000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.contract_templates + ADD COLUMN IF NOT EXISTS ethiopian_customs_only boolean + `); + + await queryRunner.query( + `DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_dir_customs`, + ); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_dir_customs + ON freight.contract_templates + (cargo_type_id, trade_direction, + COALESCE(with_customs, false), COALESCE(ethiopian_customs_only, false)) + WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL + `); + + await queryRunner.query(` + ALTER TABLE freight.contract_templates + DROP CONSTRAINT IF EXISTS ck_bulk_intercity_no_customs + `); + await queryRunner.query(` + ALTER TABLE freight.contract_templates + ADD CONSTRAINT ck_bulk_intercity_no_customs CHECK ( + cargo_type_id IS NULL + OR ( + trade_direction IN ('IMPORT', 'EXPORT', 'INTERCITY') + AND (trade_direction = 'INTERCITY') = (with_customs IS NULL) + AND (ethiopian_customs_only IS NOT TRUE OR with_customs IS TRUE) + ) + ) + `); + + for (const code of SEEDED_CODES) { + const seed = CONTRACT_TEMPLATE_DEFAULTS.find((t) => t.code === code); + if (!seed) throw new Error(`Missing contract template default for ${code}`); + await queryRunner.query( + `INSERT INTO freight.contract_templates + (id, code, name, description, document_title, whereas_clauses, articles, + is_active, is_system, created_at, updated_at) + SELECT gen_random_uuid(), $1::varchar, $2, $3, $4, $5::jsonb, $6::jsonb, + true, true, now(), now() + WHERE NOT EXISTS ( + SELECT 1 FROM freight.contract_templates + WHERE code = $1::varchar AND deleted_at IS NULL + )`, + [ + seed.code, + seed.name, + seed.description, + seed.documentTitle, + JSON.stringify(seed.whereasClauses), + JSON.stringify( + seed.articles.map((article, index) => ({ ...article, order: index + 1 })), + ), + ], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM freight.contract_templates WHERE code = ANY($1) AND is_system = true`, + [[...SEEDED_CODES]], + ); + + await queryRunner.query(` + ALTER TABLE freight.contract_templates + DROP CONSTRAINT IF EXISTS ck_bulk_intercity_no_customs + `); + await queryRunner.query(` + ALTER TABLE freight.contract_templates + ADD CONSTRAINT ck_bulk_intercity_no_customs CHECK ( + cargo_type_id IS NULL + OR ( + trade_direction IN ('IMPORT', 'EXPORT', 'INTERCITY') + AND (trade_direction = 'INTERCITY') = (with_customs IS NULL) + ) + ) + `); + + await queryRunner.query( + `DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_dir_customs`, + ); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_dir_customs + ON freight.contract_templates + (cargo_type_id, trade_direction, COALESCE(with_customs, false)) + WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL + `); + + await queryRunner.query(` + ALTER TABLE freight.contract_templates + DROP COLUMN IF EXISTS ethiopian_customs_only + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3760000000000-CompanyProfileEtradeBusiness.ts b/apps/edr-freight-api/src/migrations/3760000000000-CompanyProfileEtradeBusiness.ts new file mode 100644 index 000000000..7e2db4eb3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3760000000000-CompanyProfileEtradeBusiness.ts @@ -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 { + await queryRunner.query(` + ALTER TABLE freight.company_profiles + ADD COLUMN IF NOT EXISTS etrade_business jsonb + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.company_profiles + DROP COLUMN IF EXISTS etrade_business + `); + } +} diff --git a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts index 019d99920..50093ea67 100644 --- a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts +++ b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts @@ -12,7 +12,7 @@ * humanized handler name where a route has none. * * Excludes the AI Assist and Account entities. - * Generated from the controllers under src/ — 517 endpoints. + * Generated from the controllers under src/ — 528 endpoints. */ /** [title, method, entity] for one auditable route. */ export type AuditEndpointMeta = readonly [title: string, method: string, entity: string]; @@ -38,6 +38,7 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/bookings/:id/clearance/draft-declaration": ["GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review", "POST", "Booking"], "POST /api/bookings/:id/clearance/draft-declaration/accept": ["Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia", "POST", "Booking"], "POST /api/bookings/:id/clearance/draft-declaration/change": ["Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/draft-declaration/skip": ["GL ET skips the draft-declaration round: no estimate is sent to the customer, the real declaration is filed directly and duty & tax passes by default", "POST", "Booking"], "POST /api/bookings/:id/clearance/duty": ["GL ET sets duty/tax on booking with notice attachment", "POST", "Booking"], "POST /api/bookings/:id/clearance/duty-slip": ["Customer uploads duty/tax payment slip on booking", "POST", "Booking"], "POST /api/bookings/:id/clearance/export-release": ["Confirm Booking Export Release", "POST", "Booking"], @@ -51,7 +52,12 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/bookings/:id/clearance/charges/port-document": ["GL Djibouti uploads the port-charges document", "POST", "Booking"], "PATCH /api/bookings/:id/clearance/charges/:chargeId/bill": ["GL Ethiopia sets or revises a clearance charge's amount + currency", "PATCH", "Booking"], "POST /api/bookings/:id/clearance/charges/:chargeId/send": ["GL Ethiopia issues the clearance charge invoice to the customer", "POST", "Booking"], + "POST /api/bookings/:id/clearance/charges/:chargeId/accept": ["Customer accepts a proposed clearance charge — issues the payable invoice and locks the charge", "POST", "Booking"], + "POST /api/bookings/:id/clearance/charges/:chargeId/reject": ["Customer rejects a proposed clearance charge with a reason — GL Ethiopia revises and re-sends", "POST", "Booking"], "POST /api/bookings/:id/clearance/charges/miscellaneous": ["GL Ethiopia creates the miscellaneous clearance charge", "POST", "Booking"], + "POST /api/bookings/:id/additional-charges": ["Finance raises a new additional charge — draft, or send to the customer immediately", "POST", "Booking"], + "POST /api/bookings/:id/additional-charges/:chargeId/send": ["Issue the draft charge's payable invoice and notify the customer", "POST", "Booking"], + "POST /api/bookings/:id/additional-charges/:chargeId/cancel": ["Withdraw a draft or unpaid additional charge", "POST", "Booking"], "POST /api/bookings/:id/clearance/ro-amendment": ["Request Booking RO Amendment", "POST", "Booking"], "POST /api/bookings/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Booking"], "POST /api/bookings/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration", "POST", "Booking"], @@ -73,7 +79,7 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/bookings/:id/documents": ["Upload documents for a booking (DRAFT only)", "POST", "Booking"], "PATCH /api/bookings/:id/export-handover-mode": ["Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first", "PATCH", "Booking"], "POST /api/bookings/:id/generate-grn": ["Generate a GRN over the received containers (all received, or a subset) — one GRN per batch", "POST", "Booking"], - "POST /api/bookings/:id/generate-price": ["Generate price preview (DRAFT or CHANGES_REQUESTED)", "POST", "Booking"], + // "POST /api/bookings/:id/generate-price": ["Generate price preview (DRAFT or CHANGES_REQUESTED)", "POST", "Booking"], "POST /api/bookings/:id/government-expedite": ["Expedite government booking to PAID / ELIGIBLE for scheduling", "POST", "Booking"], "POST /api/bookings/:id/marketing/approve": ["Staff contract signature and fully execute (use contract/sign STAFF preferred)", "POST", "Booking"], "POST /api/bookings/:id/operation/review": ["Operations reviews an operation request: ACCEPT (→ batch pool),", "POST", "Booking"], @@ -85,7 +91,7 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/bookings/:id/staff/request-changes": ["Staff return booking for customer updates", "POST", "Booking"], "POST /api/bookings/:id/submit": ["Customer submit booking", "POST", "Booking"], "POST /api/bookings/:id/wagon-cancellations": ["Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles", "POST", "Booking"], - "POST /api/bookings/:id/wagon-cancellations/preview": ["Preview the fee/credit of a partial wagon cancellation (no writes)", "POST", "Booking"], + // "POST /api/bookings/:id/wagon-cancellations/preview": ["Preview the fee/credit of a partial wagon cancellation (no writes)", "POST", "Booking"], "POST /api/bookings/wagon-cancellations/:cancellationId/rebook": ["Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)", "POST", "Booking"], "POST /api/bookings/wagon-cancellations/:cancellationId/withdraw": ["Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)", "POST", "Booking"], "POST /api/bookings/consolidation-approvals/:approvalId/approve": ["Approve a shared wagon: both bookings leave the gate and continue to Operations together.", "POST", "Booking"], @@ -207,7 +213,7 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/contracts/:id/staff/request-changes": ["Staff return contract for customer updates", "POST", "Contract"], "POST /api/contracts/:id/submit": ["Customer submit contract (freezes contract_rate_snapshots)", "POST", "Contract"], "POST /api/contracts/:id/suspend": ["Staff freeze a signed contract (reversible, any post-signature step)", "POST", "Contract"], - "POST /api/contracts/:id/validate-shipment": ["Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created)", "POST", "Contract"], + // "POST /api/contracts/:id/validate-shipment": ["Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created)", "POST", "Contract"], "POST /api/contracts/booking-requests/:reqId/accept": ["GL marks a shipment request accepted + links the created booking", "POST", "Contract"], "POST /api/contracts/booking-requests/:reqId/cancel": ["Customer cancels their own pending shipment request", "POST", "Contract"], "POST /api/contracts/booking-requests/:reqId/reject": ["GL rejects a shipment request", "POST", "Contract"], @@ -240,7 +246,7 @@ export const AUDIT_ENDPOINTS: Readonly> = { "PUT /api/contract-templates/:code/articles": ["Replace the full ordered article list (used for reorder)", "PUT", "Contract Template"], "PATCH /api/contract-templates/:code/articles/:articleId": ["Update an article's title or body", "PATCH", "Contract Template"], "DELETE /api/contract-templates/:code/articles/:articleId": ["Remove an article from the template", "DELETE", "Contract Template"], - "POST /api/contract-templates/:code/preview": ["Render an HTML preview of the template against mock contract data", "POST", "Contract Template"], + // "POST /api/contract-templates/:code/preview": ["Render an HTML preview of the template against mock contract data", "POST", "Contract Template"], // Driver "POST /api/drivers": ["Create a new driver", "POST", "Driver"], @@ -266,6 +272,8 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/invoices/:id/eims/receipt/sales": ["Register a sales receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"], "POST /api/invoices/:id/eims/receipt/withholding": ["Register a withholding receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"], "POST /api/invoices/eims/bulk-cancel": ["Cancel multiple invoices", "POST", "EIMS Invoice"], + "POST /api/invoices/eims/bulk-register": ["Submit multiple invoices to MoR EIMS in one call. Asynchronous — this only confirms MoR", "POST", "EIMS Invoice"], + "POST /api/eims/webhook/bulk-register": ["EIMS bulk-register webhook callback (MoR reports per-invoice results)", "POST", "EIMS Invoice"], // Exchange Setting "PATCH /api/exchange-settings": ["Set the USD→ETB fallback by hand (used only while CBE is unreachable)", "PATCH", "Exchange Setting"], @@ -373,6 +381,14 @@ export const AUDIT_ENDPOINTS: Readonly> = { "PATCH /api/notifications/:id/read": ["Mark one of my notifications as read", "PATCH", "Notification Inbox"], "POST /api/notifications/read-all": ["Mark all my notifications as read", "POST", "Notification Inbox"], + // Operations Standard + "PATCH /api/operations-standards": ["Change one or more operating standards", "PATCH", "Operations Standard"], + + // Operations Target + "POST /api/operations-targets": ["Create a planned target", "POST", "Operations Target"], + "PATCH /api/operations-targets/:id": ["Update a planned target", "PATCH", "Operations Target"], + "DELETE /api/operations-targets/:id": ["Soft-delete a planned target", "DELETE", "Operations Target"], + // Organization User "PUT /api/backoffice/organizations/:orgId/employee-users/:userId/roles": ["Replace org-scoped roles assigned to an employee user", "PUT", "Organization User"], "POST /api/backoffice/organizations/:orgId/users": ["Create an organization user without assigning positions", "POST", "Organization User"], @@ -445,7 +461,8 @@ export const AUDIT_ENDPOINTS: Readonly> = { // Two controllers register this same path; Nest serves whichever module loads first. "POST /api/train-scheduling/schedules/:id/maintenance": ["Reschedule train for maintenance (new departure + rebalance)", "POST", "Schedule"], "POST /api/train-scheduling/schedules/:id/reschedule/execute": ["Execute a confirmed reschedule plan", "POST", "Schedule"], - "POST /api/train-scheduling/schedules/:id/reschedule/preview": ["Preview reschedule / government preempt plan", "POST", "Schedule"], + "POST /api/train-scheduling/schedules/:id/reschedule/maintenance": ["Reschedule train for maintenance (new departure + rebalance)", "POST", "Schedule"], + // "POST /api/train-scheduling/schedules/:id/reschedule/preview": ["Preview reschedule / government preempt plan", "POST", "Schedule"], // Service Type "POST /api/service-types": ["Create a service type", "POST", "Service Type"], @@ -463,7 +480,7 @@ export const AUDIT_ENDPOINTS: Readonly> = { // Shipping Line Booking "POST /api/shipping-line-bookings/initiate": ["Initiate a bare booking (no contract). Starts at AWAITING_DOCUMENTS so the shipping line can upload its documents for Operations to approve.", "POST", "Shipping Line Booking"], "POST /api/shipping-line-bookings/:id/cancel": ["Cancel one of the signed-in shipping line's own bookings. Allowed only before the booking is priced.", "POST", "Shipping Line Booking"], - "POST /api/shipping-line-bookings/:id/price-preview": ["Authoritative price quote for the completion payload — same compute as /complete, saved as the booking's breakdown + rate snapshots (refreshed on every re-preview). Persists nothing else.", "POST", "Shipping Line Booking"], + // "POST /api/shipping-line-bookings/:id/price-preview": ["Authoritative price quote for the completion payload — same compute as /complete, saved as the booking's breakdown + rate snapshots (refreshed on every re-preview). Persists nothing else.", "POST", "Shipping Line Booking"], "POST /api/shipping-line-bookings/:id/complete": ["Complete an approved (CLEARANCE_READY) booking: cargo + binding shipment day.", "POST", "Shipping Line Booking"], // Shipping Line Credit @@ -510,6 +527,8 @@ export const AUDIT_ENDPOINTS: Readonly> = { "PATCH /api/train-builder/:id/details": ["Edit the train's name and fixed import/export run numbers", "PATCH", "Train Build"], "PUT /api/train-builder/:id/locomotives": ["Replace the locomotive set (minimum 1, same yard)", "PUT", "Train Build"], "POST /api/train-builder/:id/reorder-wagons": ["Persist a drag-reorder of the full consist", "POST", "Train Build"], + "PATCH /api/train-builder/:id/wagons/:wagonId/yard": ["Move one coupled wagon to another yard — refused while any live schedule has the wagon allocated", "PATCH", "Train Build"], + "PATCH /api/train-builder/:id/wagons/yard": ["Move several coupled wagons to another yard in one transaction — refused outright if any is allocated to a live schedule", "PATCH", "Train Build"], "POST /api/train-builder/:id/wagons": ["Append AVAILABLE wagons from the train's yard to the consist", "POST", "Train Build"], "DELETE /api/train-builder/:id/wagons/:wagonId": ["Detach one wagon from the consist", "DELETE", "Train Build"], "POST /api/train-builder/:id/wagons/:wagonId/maintenance": ["Detach one wagon and move it to MAINTENANCE status", "POST", "Train Build"], @@ -520,16 +539,16 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/train-scheduling/bookings/:bookingId/expire": ["Staff: expire a reservation and free its capacity", "POST", "Train Schedule"], "POST /api/train-scheduling/bookings/:bookingId/mark-paid": ["Staff: mark a reserved booking paid and allocate it now", "POST", "Train Schedule"], "POST /api/train-scheduling/bookings/:bookingId/move-schedule": ["Re-point a booking to another OPEN same-route schedule", "POST", "Train Schedule"], - "POST /api/train-scheduling/bulk/preview": ["Preview a bulk train schedule", "POST", "Train Schedule"], + // "POST /api/train-scheduling/bulk/preview": ["Preview a bulk train schedule", "POST", "Train Schedule"], "POST /api/train-scheduling/bulk/schedules": ["Create a bulk train schedule", "POST", "Train Schedule"], "POST /api/train-scheduling/bulk/schedules/:id/assign-bookings": ["Assign bulk bookings to a train schedule", "POST", "Train Schedule"], "POST /api/train-scheduling/bulk/schedules/:id/cancel": ["Cancel bulk train schedule", "POST", "Train Schedule"], - "POST /api/train-scheduling/container/preview": ["Preview a container train schedule", "POST", "Train Schedule"], + // "POST /api/train-scheduling/container/preview": ["Preview a container train schedule", "POST", "Train Schedule"], "POST /api/train-scheduling/container/schedules": ["Create a container train schedule", "POST", "Train Schedule"], "POST /api/train-scheduling/container/schedules/:id/assign-bookings": ["Assign container bookings to a train schedule", "POST", "Train Schedule"], "POST /api/train-scheduling/container/schedules/:id/cancel": ["Cancel container train schedule", "POST", "Train Schedule"], "PATCH /api/train-scheduling/global-rules": ["Update global train scheduling rules (singleton)", "PATCH", "Train Schedule"], - "POST /api/train-scheduling/preview": ["Preview a mixed-capable train schedule", "POST", "Train Schedule"], + // "POST /api/train-scheduling/preview": ["Preview a mixed-capable train schedule", "POST", "Train Schedule"], "POST /api/train-scheduling/schedules/:id/adjust-consist": ["Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)", "POST", "Train Schedule"], "POST /api/train-scheduling/schedules/:id/arrive": ["Mark a dispatched train arrived (move assets to destination yard, free assets)", "POST", "Train Schedule"], "POST /api/train-scheduling/schedules/:id/assign-bookings": ["Assign bookings to a train schedule (mixed-capable)", "POST", "Train Schedule"], @@ -564,6 +583,7 @@ export const AUDIT_ENDPOINTS: Readonly> = { "DELETE /api/train-scheduling/schedules/:id/wagons/:trainSetWagonId": ["Remove an empty wagon slot from a train", "DELETE", "Train Schedule"], "POST /api/train-scheduling/schedules/:id/wagons/:wagonId/move-load": ["Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)", "POST", "Train Schedule"], "PATCH /api/train-scheduling/schedules/:id/window-rule": ["Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens", "PATCH", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/wagon-yards": ["Re-plan the yard this departure boards wagons from and/or cuts them at (schedule-only; physical yards untouched, dispatch requires alignment)", "PATCH", "Train Schedule"], "POST /api/train-scheduling/schedules/:id/merge": ["Merge another train into this schedule: its wagons join this consist, a same-day schedule on it is absorbed, and the emptied train is deactivated", "POST", "Train Schedule"], "PATCH /api/train-scheduling/schedules/:id/checkpoints/:sequenceNo": ["Edit a logged leg", "PATCH", "Train Schedule"], @@ -611,7 +631,7 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/warehouse-allocation-rules": ["Create a warehouse allocation rule", "POST", "Warehouse"], "PATCH /api/warehouse-allocation-rules/:id": ["Update a warehouse allocation rule", "PATCH", "Warehouse"], "DELETE /api/warehouse-allocation-rules/:id": ["Delete a warehouse allocation rule", "DELETE", "Warehouse"], - "POST /api/warehouse-allocation/preview": ["Preview the yard/warehouse/zone a booking would be allocated to", "POST", "Warehouse"], + // "POST /api/warehouse-allocation/preview": ["Preview the yard/warehouse/zone a booking would be allocated to", "POST", "Warehouse"], "POST /api/warehouse-fee-rules": ["Create a storage / demurrage fee rule", "POST", "Warehouse"], "PATCH /api/warehouse-fee-rules/:id": ["Update a fee rule", "PATCH", "Warehouse"], "DELETE /api/warehouse-fee-rules/:id": ["Delete a fee rule", "DELETE", "Warehouse"], diff --git a/apps/edr-freight-api/src/modules/audit/audit-log.repository.ts b/apps/edr-freight-api/src/modules/audit/audit-log.repository.ts index 91d6c9901..dcbe53306 100644 --- a/apps/edr-freight-api/src/modules/audit/audit-log.repository.ts +++ b/apps/edr-freight-api/src/modules/audit/audit-log.repository.ts @@ -1,10 +1,11 @@ import { Injectable } from '@nestjs/common'; import { BaseRepository } from '@edr/api-common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Between, FindOptionsWhere, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; +import { Repository } from 'typeorm'; import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; import { AuditLog } from './entities/audit-log.entity'; +import type { AuditReferenceSource } from './audit-reference.registry'; export interface AuditLogQuery { type?: string; @@ -12,6 +13,10 @@ export interface AuditLogQuery { method?: string; isSuccess?: boolean; resourceId?: string; + reference?: string; + userName?: string; + title?: string; + q?: string; from?: Date; to?: Date; skip: number; @@ -40,30 +45,91 @@ export class AuditLogRepository extends BaseRepository { ); } + /** + * Resolve the human identifier for one entity row (`WHERE id = $1`). + * + * `source` comes from the static `AUDIT_REFERENCE_SOURCES` registry — never + * from user input — so interpolating its table/column is safe; the id is + * bound as a parameter. Returns null when the row doesn't exist or the + * identifier column is empty. + */ + async lookupReference( + source: AuditReferenceSource, + id: string, + ): Promise { + const rows = await this.auditLogRepository.manager.query< + { reference: string | null }[] + >( + `SELECT ${source.column}::varchar AS reference FROM ${source.table} WHERE id = $1::uuid`, + [id], + ); + return rows[0]?.reference || null; + } + /** * Paginated, filtered read. Newest first — every index on this table is * ordered `created_at DESC` to match. + * + * Query builder rather than `findAndCount`: `q` needs an OR across four + * columns, and `reference` needs the `upper(...) LIKE` shape that matches + * the expression index — neither fits `FindOptionsWhere`. */ async search(query: AuditLogQuery): Promise<[AuditLog[], number]> { - const where: FindOptionsWhere = {}; + const qb = this.auditLogRepository.createQueryBuilder('audit_log'); - if (query.type) where.type = query.type; - if (query.userId) where.userId = query.userId; - if (query.method) where.method = query.method; - if (query.resourceId) where.resourceId = query.resourceId; - if (query.isSuccess !== undefined) where.isSuccess = query.isSuccess; + if (query.type) qb.andWhere('audit_log.type = :type', { type: query.type }); + if (query.userId) qb.andWhere('audit_log.user_id = :userId', { userId: query.userId }); + if (query.method) qb.andWhere('audit_log.method = :method', { method: query.method }); + if (query.resourceId) { + qb.andWhere('audit_log.resource_id = :resourceId', { resourceId: query.resourceId }); + } + if (query.isSuccess !== undefined) { + qb.andWhere('audit_log.is_success = :isSuccess', { isSuccess: query.isSuccess }); + } + + // Case-insensitive prefix match, shaped to hit idx_audit_logs_reference_upper. + // The explicit <> '' repeats the index's partial predicate — without it the + // planner cannot prove the partial index applies and falls back to a scan. + if (query.reference) { + qb.andWhere("audit_log.reference <> ''").andWhere( + "upper(audit_log.reference) LIKE upper(:reference) || '%'", + { reference: escapeLike(query.reference) }, + ); + } + if (query.userName) { + qb.andWhere('audit_log.user_name ILIKE :userName', { + userName: `%${escapeLike(query.userName)}%`, + }); + } + if (query.title) { + qb.andWhere('audit_log.title ILIKE :title', { + title: `%${escapeLike(query.title)}%`, + }); + } + + // One search box across the columns staff actually search by. + // ponytail: ILIKE %…% scans the time-bounded window; add pg_trgm GIN + // indexes if the table grows past a few million rows. + if (query.q) { + const q = `%${escapeLike(query.q)}%`; + qb.andWhere( + `(audit_log.reference ILIKE :q + OR audit_log.resource_id ILIKE :q + OR audit_log.user_name ILIKE :q + OR audit_log.title ILIKE :q)`, + { q }, + ); + } // Date range: either bound may be supplied alone. - if (query.from && query.to) where.createdAt = Between(query.from, query.to); - else if (query.from) where.createdAt = MoreThanOrEqual(query.from); - else if (query.to) where.createdAt = LessThanOrEqual(query.to); + if (query.from) qb.andWhere('audit_log.created_at >= :from', { from: query.from }); + if (query.to) qb.andWhere('audit_log.created_at <= :to', { to: query.to }); - return this.auditLogRepository.findAndCount({ - where, - order: { createdAt: 'DESC' }, - skip: query.skip, - take: query.take, - }); + return qb + .orderBy('audit_log.created_at', 'DESC') + .skip(query.skip) + .take(query.take) + .getManyAndCount(); } /** Distinct entity types present, for populating a filter dropdown. */ @@ -76,4 +142,20 @@ export class AuditLogRepository extends BaseRepository { return rows.map((row) => row.type); } + + /** Distinct action titles present, for the action filter dropdown. */ + async distinctTitles(): Promise { + const rows = await this.auditLogRepository + .createQueryBuilder('audit_log') + .select('DISTINCT audit_log.title', 'title') + .orderBy('audit_log.title', 'ASC') + .getRawMany<{ title: string }>(); + + return rows.map((row) => row.title); + } +} + +/** Escape LIKE wildcards so a literal `%`/`_` in the search text stays literal. */ +function escapeLike(value: string): string { + return value.replace(/[\\%_]/g, (ch) => `\\${ch}`); } diff --git a/apps/edr-freight-api/src/modules/audit/audit-reference.registry.ts b/apps/edr-freight-api/src/modules/audit/audit-reference.registry.ts new file mode 100644 index 000000000..9cba31abb --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit-reference.registry.ts @@ -0,0 +1,39 @@ +/** + * Where each audited entity type keeps its human identifier — the value staff + * search by (booking reference, train number, invoice number). + * + * Used by `AuditService.record` for a single indexed primary-key lookup at + * write time. Types not listed simply get `reference = ''`; the lookup is + * best-effort and an audit row is never lost over it. + * + * Table and column names are static values from this file — never user input — + * so interpolating them into SQL is safe. Ids are always bound as parameters. + */ +export interface AuditReferenceSource { + /** Schema-qualified table holding the entity. */ + readonly table: string; + /** Column with the human identifier. */ + readonly column: string; +} + +export const AUDIT_REFERENCE_SOURCES: Readonly> = { + Booking: { table: 'freight.bookings', column: 'reference' }, + Contract: { table: 'freight.contracts', column: 'reference' }, + // "Schedule" (reschedule module) and "Train Schedule" are the same table. + Schedule: { table: 'freight.train_schedules', column: 'reference' }, + 'Train Schedule': { table: 'freight.train_schedules', column: 'reference' }, + Train: { table: 'freight.trains', column: 'train_number' }, + // Train Build routes carry the train id in :id. + 'Train Build': { table: 'freight.trains', column: 'train_number' }, + Wagon: { table: 'freight.wagons', column: 'wagon_number' }, + Locomotive: { table: 'freight.locomotives', column: 'code' }, + 'EIMS Invoice': { table: 'freight.invoices', column: 'invoice_number' }, + // Payment paths mostly carry an invoice id; the ones that don't (e.g. + // redirect-success/:bookingId) miss the lookup and fall back to ''. + Payment: { table: 'freight.invoices', column: 'invoice_number' }, + Vehicle: { table: 'freight.vehicles', column: 'plate_number' }, + Company: { table: 'freight.companies', column: 'name' }, +}; + +/** Lookups run `WHERE id = $1::uuid` — guard non-uuid ids (template codes…). */ +export const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; diff --git a/apps/edr-freight-api/src/modules/audit/audit.controller.ts b/apps/edr-freight-api/src/modules/audit/audit.controller.ts index 1a07a5fe5..2e08b2cd9 100644 --- a/apps/edr-freight-api/src/modules/audit/audit.controller.ts +++ b/apps/edr-freight-api/src/modules/audit/audit.controller.ts @@ -43,4 +43,13 @@ export class AuditController { types(): Promise { return this.auditService.listTypes(); } + + @Get('actions') + @BookingStaff(FREIGHT_PERMS.auditLog.view) + @ApiOperation({ + summary: 'Distinct action titles present in the audit log (filter dropdown)', + }) + actions(): Promise { + return this.auditService.listActions(); + } } diff --git a/apps/edr-freight-api/src/modules/audit/audit.service.ts b/apps/edr-freight-api/src/modules/audit/audit.service.ts index de7dfd956..9816d79f5 100644 --- a/apps/edr-freight-api/src/modules/audit/audit.service.ts +++ b/apps/edr-freight-api/src/modules/audit/audit.service.ts @@ -4,6 +4,10 @@ import { PaginatedResponse } from '@edr/types'; import { AuditLog } from './entities/audit-log.entity'; import { AuditLogRepository } from './audit-log.repository'; import { AuditLogQueryDto } from './dto/audit-log-query.dto'; +import { + AUDIT_REFERENCE_SOURCES, + UUID_PATTERN, +} from './audit-reference.registry'; import { buildPaginationMeta, normalizePagination, @@ -25,6 +29,7 @@ export class AuditService { */ async record(entry: Partial): Promise { try { + entry.reference = await this.resolveReference(entry.type, entry.resourceId); await this.auditLogRepository.record(entry); } catch (error) { this.logger.error( @@ -35,6 +40,34 @@ export class AuditService { } } + /** + * Best-effort human identifier (booking reference, train number, …) for the + * entity the action touched — one primary-key lookup against the table + * registered for the type. Always returns a string: '' when the type has no + * registered source, the id isn't a uuid (template codes), the row is gone, + * or the lookup itself fails. A missing reference must never cost the audit + * row, so failures degrade to '' rather than throwing. + */ + private async resolveReference( + type: string | undefined, + resourceId: string | null | undefined, + ): Promise { + const source = type ? AUDIT_REFERENCE_SOURCES[type] : undefined; + if (!source || !resourceId || !UUID_PATTERN.test(resourceId)) return ''; + + try { + const reference = await this.auditLogRepository.lookupReference(source, resourceId); + return reference?.slice(0, 64) ?? ''; + } catch (error) { + this.logger.warn( + `Reference lookup failed for ${type} ${resourceId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return ''; + } + } + /** Paginated, filtered audit history, newest first. */ async search(query: AuditLogQueryDto): Promise> { const { page, pageSize, skip, take } = normalizePagination(query); @@ -53,6 +86,10 @@ export class AuditService { userId: query.userId, method: query.method, resourceId: query.resourceId, + reference: query.reference, + userName: query.userName, + title: query.title, + q: query.q, isSuccess: query.isSuccess === undefined ? undefined : query.isSuccess === 'true', from, @@ -68,4 +105,9 @@ export class AuditService { async listTypes(): Promise { return this.auditLogRepository.distinctTypes(); } + + /** Distinct action titles, for the action filter dropdown. */ + async listActions(): Promise { + return this.auditLogRepository.distinctTitles(); + } } diff --git a/apps/edr-freight-api/src/modules/audit/dto/audit-log-query.dto.ts b/apps/edr-freight-api/src/modules/audit/dto/audit-log-query.dto.ts index 5a5199538..dbd41e14c 100644 --- a/apps/edr-freight-api/src/modules/audit/dto/audit-log-query.dto.ts +++ b/apps/edr-freight-api/src/modules/audit/dto/audit-log-query.dto.ts @@ -39,6 +39,44 @@ export class AuditLogQueryDto extends PaginationQueryDto { @MaxLength(64) resourceId?: string; + @ApiPropertyOptional({ + description: + 'Human identifier of the affected record — booking reference, schedule number, train number. Case-insensitive prefix match.', + example: 'S-2026-00045', + }) + @IsOptional() + @IsString() + @MaxLength(64) + reference?: string; + + @ApiPropertyOptional({ + description: 'Staff name, case-insensitive substring match.', + example: 'Mulu', + }) + @IsOptional() + @IsString() + @MaxLength(150) + userName?: string; + + @ApiPropertyOptional({ + description: 'Action title, case-insensitive substring match.', + example: 'Cancel booking', + }) + @IsOptional() + @IsString() + @MaxLength(255) + title?: string; + + @ApiPropertyOptional({ + description: + 'Free-text search across reference, resource id, staff name and action title.', + example: 'B-2026-00120', + }) + @IsOptional() + @IsString() + @MaxLength(100) + q?: string; + @ApiPropertyOptional({ description: 'Filter by outcome: true = succeeded, false = failed.', }) diff --git a/apps/edr-freight-api/src/modules/audit/entities/audit-log.entity.ts b/apps/edr-freight-api/src/modules/audit/entities/audit-log.entity.ts index 0ff7727ff..8261fc61c 100644 --- a/apps/edr-freight-api/src/modules/audit/entities/audit-log.entity.ts +++ b/apps/edr-freight-api/src/modules/audit/entities/audit-log.entity.ts @@ -86,6 +86,20 @@ export class AuditLog { @Column({ name: 'resource_id', type: 'varchar', length: 64, nullable: true }) resourceId?: string | null; + /** + * Human identifier of the affected record — booking reference, schedule + * number, train number — resolved at write time from + * `AUDIT_REFERENCE_SOURCES`. This is what staff type into the search box; + * `resourceId` stays the machine id. + * + * `''` (never NULL) when the entity type has no registered source, the + * lookup found nothing, or the row predates the column. Empty string keeps + * search SQL to one shape and matches how pre-existing rows read after the + * metadata-only migration. + */ + @Column({ name: 'reference', type: 'varchar', length: 64, default: '' }) + reference!: string; + /** * Sanitized request body. Secrets are replaced with `[REDACTED]` and uploads * are reduced to `{ __file, originalName, mimeType, size }` descriptors — diff --git a/apps/edr-freight-api/src/modules/auth/account.controller.ts b/apps/edr-freight-api/src/modules/auth/account.controller.ts index d7d7f15c3..96f90e8a3 100644 --- a/apps/edr-freight-api/src/modules/auth/account.controller.ts +++ b/apps/edr-freight-api/src/modules/auth/account.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Patch, Post, UseGuards } from "@nestjs/common"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator"; -import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard"; +import { FreightJwtGuard } from "../../common/freight-jwt.guard"; import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; import { AccountService } from "./account.service"; @@ -19,7 +19,7 @@ import { @ApiTags("auth") @Controller("me") @ApiBearerAuth() -@UseGuards(JwtGuard) +@UseGuards(FreightJwtGuard) export class AccountController { constructor(private readonly accountService: AccountService) {} diff --git a/apps/edr-freight-api/src/modules/auth/customer-accounts.service.ts b/apps/edr-freight-api/src/modules/auth/customer-accounts.service.ts new file mode 100644 index 000000000..d051e268b --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/customer-accounts.service.ts @@ -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, + @InjectRepository(User) + private readonly users: Repository, + ) {} + + /** + * 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 { + 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(); + }); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts b/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts index 52a900fe8..8da218193 100644 --- a/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts +++ b/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts @@ -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 { + return this.customerAccountsService.listForCompany(companyId); + } @Get(":companyId/reset-target") @BookingStaff(FREIGHT_PERMS.customers.resetPassword) diff --git a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts index 557e50fb3..9afe9efc0 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts @@ -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. diff --git a/apps/edr-freight-api/src/modules/auth/freight-me.controller.ts b/apps/edr-freight-api/src/modules/auth/freight-me.controller.ts index b85ecea84..f1c974687 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-me.controller.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-me.controller.ts @@ -1,7 +1,7 @@ import { Controller, Get, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CurrentUser } from '@tria-plc/api-common/modules/auth/decorators/current-user.decorator'; -import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { FreightJwtGuard } from '../../common/freight-jwt.guard'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { FreightMeService } from './freight-me.service'; @@ -13,7 +13,7 @@ export class FreightMeController { constructor(private readonly freightMeService: FreightMeService) {} @Get() - @UseGuards(JwtGuard) + @UseGuards(FreightJwtGuard) @ApiOperation({ summary: 'Current user with flat permissionKeys for backoffice gating', }) diff --git a/apps/edr-freight-api/src/modules/auth/freight-me.service.ts b/apps/edr-freight-api/src/modules/auth/freight-me.service.ts index 6bcfd964d..b897c5fef 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-me.service.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-me.service.ts @@ -9,6 +9,9 @@ import { } from '../../common/freight-permission.util'; import { PERMISSIONS_CATALOG } from '../../seed/freight-permissions.registry'; +/** One position as the session snapshot carries it. */ +type TokenPosition = NonNullable['position']; + @Injectable() export class FreightMeService { constructor(@InjectDataSource() private readonly dataSource: DataSource) {} @@ -65,49 +68,63 @@ export class FreightMeService { } async getEnrichedProfile(user: TCurrentUser) { - const positionId = user.employee?.position?.id; - const [positionType, positionTypePermissionKeys] = await Promise.all([ - this.lookupPositionType(positionId), - this.lookupPositionTypePermissions(positionId), - ]); + const employeeRecord = user.employee as + | (typeof user.employee & { positions?: TokenPosition[] }) + | undefined; - // Merge the type-level grants into the position's own permission list so - // BOTH consumers see them: `collectPermissionKeys` below, and the - // backoffice's `getPermissionKeys`, which walks this same nested array. - const positionPermissions = [ - ...(user.employee?.position?.permissions ?? []), - ]; - const seenPermissionKeys = new Set( - positionPermissions.map((p) => p?.key).filter(Boolean), + // `FreightJwtGuard` restores every position the login snapshot holds; the + // stock IAM guard only ever leaves the single `position`. Fall back to it + // so a request that somehow skipped our guard still resolves one post + // rather than none. + const rawPositions: TokenPosition[] = employeeRecord?.positions?.length + ? employeeRecord.positions + : employeeRecord?.position + ? [employeeRecord.position] + : []; + + const enrichedPositions = await Promise.all( + rawPositions.map(async (position) => { + const [positionType, positionTypePermissionKeys] = await Promise.all([ + this.lookupPositionType(position.id), + this.lookupPositionTypePermissions(position.id), + ]); + + // Merge the type-level grants into this position's own permission list + // so BOTH consumers see them: `collectPermissionKeys` below, and the + // backoffice's `getPermissionKeys`, which walks this nested array. + const permissions = [...(position.permissions ?? [])]; + const seen = new Set(permissions.map((p) => p?.key).filter(Boolean)); + for (const key of positionTypePermissionKeys) { + if (!seen.has(key)) { + seen.add(key); + permissions.push({ key } as (typeof permissions)[number]); + } + } + + return { + positionTypePermissionKeys, + position: { + id: position.id, + key: position.key, + employeePositionId: position.employeePositionId, + name: position.name, + isDelegate: position.isDelegate, + parentPositionId: position.parentPositionId, + permissions, + positionType, + }, + }; + }), ); - for (const key of positionTypePermissionKeys) { - if (!seenPermissionKeys.has(key)) { - seenPermissionKeys.add(key); - positionPermissions.push({ key } as (typeof positionPermissions)[number]); - } - } - const employee = user.employee + const employee = employeeRecord ? [ { - id: user.employee.id, - organizationId: user.employee.organizationId, - unitId: user.employee.unitId, - name: user.employee.name, - positions: user.employee.position - ? [ - { - id: user.employee.position.id, - key: user.employee.position.key, - employeePositionId: user.employee.position.employeePositionId, - name: user.employee.position.name, - isDelegate: user.employee.position.isDelegate, - parentPositionId: user.employee.position.parentPositionId, - permissions: positionPermissions, - positionType, - }, - ] - : [], + id: employeeRecord.id, + organizationId: employeeRecord.organizationId, + unitId: employeeRecord.unitId, + name: employeeRecord.name, + positions: enrichedPositions.map((p) => p.position), }, ] : []; @@ -118,7 +135,7 @@ export class FreightMeService { const permissionKeys = [ ...new Set([ ...collectPermissionKeys(user), - ...positionTypePermissionKeys, + ...enrichedPositions.flatMap((p) => p.positionTypePermissionKeys), ]), ]; diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index a3ed3e417..f07e6d50f 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -939,8 +939,12 @@ describe("BillingService.document", () => { const build = (invoice: Record) => { 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); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 175e3e00c..cf41998ba 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -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; + }> { 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 = {}; + 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("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 }); diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts index ebdf51be1..c443fad7e 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts @@ -1,4 +1,4 @@ -import { InvoiceDocumentModel, InvoiceDocumentService } from "./invoice-document.service"; +import { InvoiceDocumentModel, InvoiceDocumentService, sameCompanyName } from "./invoice-document.service"; const model = (over: Partial = {}): 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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts index 0f8e4ee71..d85facc0f 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts @@ -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)); diff --git a/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.spec.ts b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.spec.ts new file mode 100644 index 000000000..2d10d6f4d --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.spec.ts @@ -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(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts index 172e74c17..994208229 100644 --- a/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts +++ b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts index 8babc8c2e..07d4c4e06 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts @@ -40,3 +40,70 @@ describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => { expect(cut.weightTons).toBeCloseTo(62.625, 3); }); }); + +/** + * Odd-20ft credit rebook: the rebooked booking shares a wagon again, so GL + * must pick the consolidation partner — no partner, no rebook; a partner + * already paired elsewhere is refused. + */ +describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () => { + const units = Array.from({ length: 3 }, (_, i) => ({ + containerSize: '20ft', + containerNumber: `CONT${i}`, + sealNumber: null, + vgmTons: 10, + isHazardous: false, + isReefer: false, + })); + const row = { + id: 'wc1', + bookingId: 'b1', + status: 'CREDIT_AVAILABLE', + creditAmount: 100, + cancelledQuantities: { bySize: { '20ft': 3 }, units }, + }; + const source = { + id: 'b1', + contractId: 'c1', + paymentCurrency: 'USD', + originYardId: 'y1', + destinationYardId: 'y2', + tradeDirection: 'IMPORT', + }; + + const makeSvc = (partner?: unknown) => { + const svc = Object.create(BookingWagonCancellationService.prototype) as Record< + string, + unknown + > & { + rebook(id: string, dto: unknown): Promise; + }; + svc.repo = { findById: async () => row }; + svc.bookingsRepository = { + findById: async () => source, + findByIdWithFiles: async () => partner ?? null, + }; + return svc; + }; + + it('refuses an odd-20ft rebook without a GL-picked partner', async () => { + await expect( + makeSvc().rebook('wc1', { scheduledDate: '2026-09-01' }), + ).rejects.toThrow(/pick a consolidation partner/i); + }); + + it('refuses a partner that already shares a wagon', async () => { + const paired = { + id: 'p1', + reference: 'BK-1', + status: 'SUBMITTED', + consolidationPartnerId: 'someone-else', + }; + await expect( + makeSvc(paired).rebook('wc1', { + scheduledDate: '2026-09-01', + partnerBookingId: 'p1', + }), + ).rejects.toThrow(/already shares a wagon/i); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index d3e3edfe5..808421a0c 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -7,7 +7,7 @@ import { Logger, NotFoundException, } from '@nestjs/common'; -import { OnEvent } from '@nestjs/event-emitter'; +import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; import { ExchangeService } from '@edr/api-common'; import { Freight, NotificationAudience, NotificationType } from '@edr/types'; import { DataSource, EntityManager, In, IsNull } from 'typeorm'; @@ -126,6 +126,7 @@ export class BookingWagonCancellationService { @Inject(forwardRef(() => FirstMileService)) private readonly firstMile: FirstMileService, private readonly inbox: NotificationInboxService, + private readonly events: EventEmitter2, ) {} // ── T1: request ──────────────────────────────────────────────────────────── @@ -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 { + const partner = await this.bookingsRepository.findByIdWithFiles(partnerId); + if (!partner) { + throw new NotFoundException(`Partner booking ${partnerId} not found.`); + } + if (partner.consolidationPartnerId) { + throw new ConflictException( + `Booking ${partner.reference} already shares a wagon with another booking.`, + ); + } + if (!['SUBMITTED', 'PENDING_CONSOLIDATION'].includes(partner.status)) { + throw new BadRequestException( + `Booking ${partner.reference} cannot be consolidated (status ${partner.status}).`, + ); + } + if ( + partner.originYardId !== source.originYardId || + partner.destinationYardId !== source.destinationYardId || + partner.tradeDirection !== source.tradeDirection + ) { + throw new BadRequestException( + `Booking ${partner.reference} rides a different route/direction — it cannot share a wagon with this rebooking.`, + ); + } + const eatDay = (d: Date | string) => + new Date(d).toLocaleDateString('en-CA', { timeZone: 'Africa/Addis_Ababa' }); + if (!partner.scheduledDate || eatDay(partner.scheduledDate) !== eatDay(scheduledDate)) { + throw new BadRequestException( + `Booking ${partner.reference} is not booked for ${eatDay(scheduledDate)} — a shared wagon must board one train.`, + ); + } + const ft20 = (partner.bookingContainers ?? []) + .filter((line) => Number(line.containerType?.sizeFt) === 20) + .reduce((sum, line) => sum + Number(line.quantity || 0), 0); + if (ft20 % 2 !== 1) { + throw new BadRequestException( + `Booking ${partner.reference} has no odd 20ft container — nothing to consolidate.`, + ); + } + return partner; + } + + /** + * Link the rebooked (already PAID) booking with the GL-picked partner. A + * parked partner is resumed the way pairConsolidation would resume it — + * but only the partner: the rebooked side's PAID status must survive, so + * the link is written directly. The paired event then runs the partner's + * deferred contract finalize (invoice → pay window); the shared wagon + * boards once that invoice is paid. + */ + private async pairRebookedBooking( + newBookingId: string, + partner: Booking, + ): Promise { + // ponytail: validate-then-link without a row lock — a concurrent claim in + // this window loses silently; move to pairConsolidationIfUnpaired-style + // locking if it ever bites. + const fresh = await this.dataSource.getRepository(Booking).findOne({ + where: { id: partner.id }, + select: { id: true, consolidationPartnerId: true, status: true }, + }); + if (!fresh || fresh.consolidationPartnerId) { + throw new ConflictException( + `Booking ${partner.reference} was claimed by another consolidation while rebooking — pick another partner.`, + ); + } + if (fresh.status === 'PENDING_CONSOLIDATION') { + await this.dataSource.getRepository(Booking).update(partner.id, { + status: partner.consolidationResumeStatus ?? 'SUBMITTED', + consolidationResumeStatus: null, + }); + } + await this.bookingsRepository.linkConsolidationPartners( + newBookingId, + partner.id, + ); + this.events.emit('booking.consolidation.paired', { + bookingIds: [partner.id], + }); + this.notifyCustomer( + partner, + 'Consolidation partner found', + `${partner.reference} now shares a wagon with a rebooked shipment. Pay your booking to board — the shared wagon ships once both halves are paid.`, + ); + } + // ── History ──────────────────────────────────────────────────────────────── list(filter: WagonCancellationListFilter) { diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 04ebc2d41..c76655240 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -725,6 +725,30 @@ export class BookingsController { return this.wagonCancellationService.withdraw(cancellationId); } + @Get("wagon-cancellations/:cancellationId/rebook-partners") + @ApiOperation({ + summary: + "Consolidation partner candidates for rebooking an odd-20ft credit on the given day (GL picks who shares the rebooked wagon)", + }) + async listRebookPartners( + @Param("cancellationId", ParseUUIDPipe) cancellationId: string, + @Query("scheduledDate") scheduledDate: string, + @CurrentUser() user: TCurrentUser, + ) { + await this.assertWagonCancellationActor( + cancellationId, + user, + FREIGHT_PERMS.bookings.wagonCancellationRebook, + ); + if (!scheduledDate) { + throw new BadRequestException("scheduledDate is required."); + } + return this.wagonCancellationService.rebookPartnerCandidates( + cancellationId, + scheduledDate, + ); + } + @Post("wagon-cancellations/:cancellationId/rebook") @ApiOperation({ summary: diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index ff0888bef..885486031 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -377,6 +377,58 @@ export class BookingsRepository extends BaseRepository { }); } + /** + * Candidate partners for rebooking an odd-20ft cancellation credit: unpaired + * odd-20ft bookings on the same route/direction riding the requested day — + * SUBMITTED (committed direct booking) or parked PENDING_CONSOLIDATION. + * Unlike {@link findManualConsolidationCandidates} this is not customs-only: + * GL picks who shares the rebooked wagon whatever the contract kind. + */ + async findRebookConsolidationCandidates( + booking: Booking, + scheduledDate: Date, + limit = 50, + ): Promise { + const rows = await this.repository + .createQueryBuilder('b') + .leftJoinAndSelect('b.bookingContainers', 'bc') + .leftJoinAndSelect('bc.containerType', 'ct') + .leftJoinAndSelect('b.company', 'company') + .where('b.id != :bookingId', { bookingId: booking.id }) + .andWhere('b.consolidationPartnerId IS NULL') + .andWhere('b.originYardId = :originYardId', { + originYardId: booking.originYardId, + }) + .andWhere('b.destinationYardId = :destinationYardId', { + destinationYardId: booking.destinationYardId, + }) + .andWhere('b.tradeDirection = :tradeDirection', { + tradeDirection: booking.tradeDirection, + }) + .andWhere('b.status IN (:...statuses)', { + statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'], + }) + // Same EAT booking day as the rebook — the pair shares one physical + // wagon, so it must board one train. + .andWhere( + `DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`, + { bookingDate: scheduledDate }, + ) + .orderBy('b.createdAt', 'ASC') + .take(limit) + .getMany(); + + // Odd-20ft test in memory (two 20ft per wagon: odd + odd = whole wagons). + return rows.filter((row) => { + const lines = row.bookingContainers ?? []; + if (lines.length === 0) return false; + const ft20 = lines + .filter((line) => Number(line.containerType?.sizeFt) === 20) + .reduce((sum, line) => sum + Number(line.quantity || 0), 0); + return ft20 % 2 === 1; + }); + } + /** * Find another booking whose container quantity complements this one to fill whole wagon(s) * (same route, same container type, partial wagon on both sides). Only 20ft lines ever diff --git a/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts index 624762fac..631e78109 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts @@ -121,6 +121,15 @@ export class RebookCancelledWagonsDto { @ValidateNested({ each: true }) @Type(() => RebookContainerLineDto) containers?: RebookContainerLineDto[]; + + @ApiPropertyOptional({ + description: + 'Required when the credit carries an odd 20ft count: the odd-20ft booking ' + + 'GL picked to share the rebooked wagon (see the rebook-partners endpoint).', + }) + @IsOptional() + @IsUUID() + partnerBookingId?: string; } export class FilterWagonCancellationsDto { diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index c85e15cfb..67a486068 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -359,6 +359,12 @@ export class Booking extends BaseEntity { @Column({ name: 'customs_clearing_agent', type: 'varchar', length: 200, nullable: true }) customsClearingAgent?: string | null; + @Column({ name: 'customs_clearing_agent_email', type: 'varchar', length: 200, nullable: true }) + customsClearingAgentEmail?: string | null; + + @Column({ name: 'customs_clearing_agent_phone', type: 'varchar', length: 50, nullable: true }) + customsClearingAgentPhone?: string | null; + @Column({ name: 'equipment_return', type: 'varchar', length: 20 }) equipmentReturn!: string; @@ -411,6 +417,23 @@ export class Booking extends BaseEntity { @Column({ name: 'bulk_total_weight_tons', type: 'numeric', precision: 12, scale: 3, nullable: true }) bulkTotalWeightTons?: number | null; + /** + * NUMBER_OF_WAGONS bulk only: the wagon count the customer asked for at + * booking. Allocation and PER_WAGON pricing use this count verbatim, and the + * cargo weight spreads evenly across it (weight ÷ count per wagon — validated + * against wagon capacity at creation). Null for every other cargo unit. + */ + @Column({ name: 'bulk_requested_wagons', type: 'int', nullable: true }) + bulkRequestedWagons?: number | null; + + /** + * NUMBER_OF_WAGONS bulk only: optional informational item count entered with + * the weight. Never prices or sizes anything (unlike PER_ITEM, where the + * count lives in cargoTotalWeightVgm). + */ + @Column({ name: 'bulk_item_count', type: 'int', nullable: true }) + bulkItemCount?: number | null; + @Column({ name: 'is_hazardous', type: 'boolean', default: false }) isHazardous!: boolean; diff --git a/apps/edr-freight-api/src/modules/chat/chat.controller.ts b/apps/edr-freight-api/src/modules/chat/chat.controller.ts index 0ecca04c0..5e122bdd2 100644 --- a/apps/edr-freight-api/src/modules/chat/chat.controller.ts +++ b/apps/edr-freight-api/src/modules/chat/chat.controller.ts @@ -1,7 +1,7 @@ import { Controller, Get, Post, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CurrentUser } from '@tria-plc/api-common/modules/auth/decorators/current-user.decorator'; -import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { FreightJwtGuard } from '../../common/freight-jwt.guard'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { ChatSync } from '../../common/booking-guards'; @@ -18,7 +18,7 @@ export class ChatController { ) {} @Get('sso') - @UseGuards(JwtGuard) + @UseGuards(FreightJwtGuard) @ApiOperation({ summary: 'One-click sign-in link into EDR internal chat' }) getSso(@CurrentUser() user: TCurrentUser) { return this.sso.getSsoUrl(user); diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 5d29b32f6..000b0733f 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -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 { 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 { + 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 { + 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); } diff --git a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts index 30c323ac3..da2497d90 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts @@ -162,7 +162,16 @@ function makeService(overrides: Partial = {}) { {} 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" } }); diff --git a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts index 96dc3ff53..b801a793f 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts @@ -133,7 +133,16 @@ function makeService(overrides: Partial = {}) { {} 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(); }); diff --git a/apps/edr-freight-api/src/modules/companies/companies.profile-etrade-business.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.profile-etrade-business.spec.ts new file mode 100644 index 000000000..c04ce40f9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.profile-etrade-business.spec.ts @@ -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 = {}) { + const company = { + id: "company-1", + tin: "0045014036", + type: "customer", + attributes, + companyProfiles: [{ id: "profile-1", type: ProfileType.exporter }], + }; + + const created: Record[] = []; + const companyProfilesRepo = { + findByCompanyId: jest.fn(async () => created), + findByType: jest.fn(async () => null), + create: jest.fn(async (row: Record) => { + created.push({ id: `profile-${created.length + 2}`, ...row }); + return created[created.length - 1]; + }), + update: jest.fn(async (id: string, data: Record) => ({ + 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).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([]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts index 17e3631e0..673e0fe64 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -84,6 +84,7 @@ export class CompaniesRepository extends BaseRepository { createdTo, onboardingCompleted, hasPendingChangeRequest, + profileType, sortBy = 'review', sortOrder = 'DESC', } = query; @@ -138,20 +139,46 @@ export class CompaniesRepository extends BaseRepository { 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 diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index d7151f200..1080ceb12 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -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 { + 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 { 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 { + 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 { + 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 { + 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, diff --git a/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts index 838c42111..8809310cc 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts @@ -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[]; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/attach-etrade-business.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/attach-etrade-business.dto.ts new file mode 100644 index 000000000..3ee50b51a --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/attach-etrade-business.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts index 9ac6c13b7..9bb5453ee 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts index d82093336..db58d0bd7 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts @@ -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 { diff --git a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts index 18b816a2a..33a5f4131 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts index 49dad4b8d..a31e517d5 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts @@ -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 { diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index 7c4348e4f..321d739e3 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -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 | 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; diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts index 9bba9396e..0a16343f4 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts @@ -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 | null; diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade-business-selection.spec.ts b/apps/edr-freight-api/src/modules/companies/services/etrade-business-selection.spec.ts index 86117d306..1400c069d 100644 --- a/apps/edr-freight-api/src/modules/companies/services/etrade-business-selection.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/services/etrade-business-selection.spec.ts @@ -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( diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts index fac57238a..9a258099c 100644 --- a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts +++ b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts @@ -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 { + 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 { + 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 || "", + }; } diff --git a/apps/edr-freight-api/src/modules/contract-templates/bulk-template-direction.spec.ts b/apps/edr-freight-api/src/modules/contract-templates/bulk-template-direction.spec.ts index b822387da..1c1b304c9 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/bulk-template-direction.spec.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/bulk-template-direction.spec.ts @@ -34,15 +34,27 @@ describe('bulkTemplateCode', () => { expect(bulkTemplateCode('STEEL', 'INTERCITY', null)).toBe('BULK_INTERCITY_STEEL'); }); - it('produces 5 distinct codes per cargo type', () => { + it('gives the Ethiopian-customs-only variant its own suffix', () => { + expect(bulkTemplateCode('STEEL', 'IMPORT', true, true)).toBe( + 'BULK_IMPORT_STEEL_ETHIOPIAN_CUSTOMS', + ); + // The flag is meaningless without customs clearing. + expect(bulkTemplateCode('STEEL', 'IMPORT', false, true)).toBe( + 'BULK_IMPORT_STEEL_NO_CUSTOMS', + ); + }); + + it('produces 7 distinct codes per cargo type', () => { const codes = [ bulkTemplateCode('STEEL', 'IMPORT', true), + bulkTemplateCode('STEEL', 'IMPORT', true, true), bulkTemplateCode('STEEL', 'IMPORT', false), bulkTemplateCode('STEEL', 'EXPORT', true), + bulkTemplateCode('STEEL', 'EXPORT', true, true), bulkTemplateCode('STEEL', 'EXPORT', false), bulkTemplateCode('STEEL', 'INTERCITY', null), ]; - expect(new Set(codes).size).toBe(5); + expect(new Set(codes).size).toBe(7); }); }); @@ -112,6 +124,33 @@ describe('ContractTemplatesService bulk create/resolve', () => { ).rejects.toBeInstanceOf(BadRequestException); }); + it('creates the Ethiopian-customs-only variant alongside the full-customs one', async () => { + const { service } = build(); + const created = await service.create({ + cargoTypeId: 'cargo-1', + tradeDirection: 'IMPORT', + withCustoms: true, + ethiopianCustomsOnly: true, + }); + expect(created.code).toBe('BULK_IMPORT_STEEL_ETHIOPIAN_CUSTOMS'); + expect(created.ethiopianCustomsOnly).toBe(true); + expect(created.documentTitle).toBe( + 'Steel Transportation and Ethiopian Customs Clearance Services', + ); + }); + + it('rejects Ethiopian-customs-only without customs clearing', async () => { + const { service } = build(); + await expect( + service.create({ + cargoTypeId: 'cargo-1', + tradeDirection: 'IMPORT', + withCustoms: false, + ethiopianCustomsOnly: true, + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + it('resolves a domestic bulk contract to the intercity template, ignoring its customs flag', async () => { const { repository, service } = build(); await service.findActiveForContract('DOMESTIC', 'BULK', true, 'cargo-1'); @@ -119,6 +158,7 @@ describe('ContractTemplatesService bulk create/resolve', () => { 'cargo-1', 'INTERCITY', null, + false, ); }); @@ -129,6 +169,18 @@ describe('ContractTemplatesService bulk create/resolve', () => { 'cargo-1', 'IMPORT', false, + false, + ); + }); + + it('resolves an Ethiopian-customs-only contract to the Ethiopian variant', async () => { + const { repository, service } = build(); + await service.findActiveForContract('IMPORT', 'BULK', true, 'cargo-1', true); + expect(repository.findActiveBulkTemplate).toHaveBeenCalledWith( + 'cargo-1', + 'IMPORT', + true, + true, ); }); }); diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts index 803814967..50a912a1d 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts @@ -16,6 +16,22 @@ describe('contractTemplateCodeFor', () => { ); }); + it('resolves the Ethiopian variant only when customs clearing is enabled', () => { + expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', true, true)).toBe( + 'IMPORT_CONTAINER_ETHIOPIAN_CUSTOMS', + ); + expect(contractTemplateCodeFor('EXPORT', 'BULK', true, true)).toBe( + 'EXPORT_BULK_ETHIOPIAN_CUSTOMS', + ); + // Without customs clearing the Ethiopian flag is meaningless. + expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', false, true)).toBe( + 'IMPORT_CONTAINER_NO_CUSTOMS', + ); + expect(contractTemplateCodeFor('DOMESTIC', 'CONTAINER', true, true)).toBe( + 'INTERCITY_CONTAINER', + ); + }); + it('never gives intercity a customs variant — it crosses no border', () => { for (const flag of [true, false, null, undefined]) { expect(contractTemplateCodeFor('DOMESTIC', 'BULK', flag)).toBe('INTERCITY_BULK'); @@ -40,7 +56,11 @@ describe('contractTemplateCodeFor', () => { for (const d of directions) { for (const f of freights) { for (const c of [true, false]) { - expect(CONTRACT_TEMPLATE_CODES).toContain(contractTemplateCodeFor(d, f, c)); + for (const e of [true, false, undefined]) { + expect(CONTRACT_TEMPLATE_CODES).toContain( + contractTemplateCodeFor(d, f, c, e), + ); + } } } } @@ -48,9 +68,9 @@ describe('contractTemplateCodeFor', () => { }); describe('CONTRACT_TEMPLATE_DEFAULTS', () => { - it('seeds exactly the ten declared codes, once each', () => { + it('seeds exactly the fourteen declared codes, once each', () => { const seeded = CONTRACT_TEMPLATE_DEFAULTS.map((t) => t.code).sort(); - expect(seeded).toHaveLength(10); + expect(seeded).toHaveLength(14); expect(seeded).toEqual([...CONTRACT_TEMPLATE_CODES].sort()); }); diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts index 1df3b1302..58c452f0c 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts @@ -1,7 +1,7 @@ import { BaseRepository } from "@edr/api-common"; import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { IsNull, Repository } from "typeorm"; +import { Repository } from "typeorm"; import { CargoType } from "../rule-engine/entities/cargo-type.entity"; import { @@ -33,10 +33,22 @@ export class ContractTemplatesRepository extends BaseRepository { - return this.repository.findOne({ - where: { cargoTypeId, tradeDirection, withCustoms: withCustoms ?? IsNull() }, - }); + return this.repository + .createQueryBuilder("t") + .where("t.cargo_type_id = :cargoTypeId", { cargoTypeId }) + .andWhere("t.trade_direction = :tradeDirection", { tradeDirection }) + .andWhere( + withCustoms === null + ? "t.with_customs IS NULL" + : "t.with_customs = :withCustoms", + withCustoms === null ? {} : { withCustoms }, + ) + .andWhere("COALESCE(t.ethiopian_customs_only, false) = :ethiopianCustomsOnly", { + ethiopianCustomsOnly, + }) + .getOne(); } /** @@ -49,6 +61,7 @@ export class ContractTemplatesRepository extends BaseRepository { return this.repository .createQueryBuilder("t") @@ -60,6 +73,9 @@ export class ContractTemplatesRepository extends BaseRepository = { IMPORT_BULK_CUSTOMS: "IMP_BULK_USD_FORWARDING", + IMPORT_BULK_ETHIOPIAN_CUSTOMS: "IMP_BULK_USD_FORWARDING", IMPORT_BULK_NO_CUSTOMS: "IMP_BULK_USD_TRANSPORT_ONLY", EXPORT_BULK_CUSTOMS: "EXP_BULK_USD_FORWARDING", + EXPORT_BULK_ETHIOPIAN_CUSTOMS: "EXP_BULK_USD_FORWARDING", EXPORT_BULK_NO_CUSTOMS: "EXP_BULK_USD_TRANSPORT_ONLY", INTERCITY_BULK: "DOM_BULK_USD_TRANSPORT_ONLY", IMPORT_CONTAINER_CUSTOMS: "IMP_CON_USD_FORWARDING", + IMPORT_CONTAINER_ETHIOPIAN_CUSTOMS: "IMP_CON_USD_FORWARDING", IMPORT_CONTAINER_NO_CUSTOMS: "IMP_CON_USD_TRANSPORT_ONLY", EXPORT_CONTAINER_CUSTOMS: "EXP_CON_USD_FORWARDING", + EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS: "EXP_CON_USD_FORWARDING", EXPORT_CONTAINER_NO_CUSTOMS: "EXP_CON_USD_TRANSPORT_ONLY", INTERCITY_CONTAINER: "DOM_CON_USD_TRANSPORT_ONLY", }; @@ -101,7 +105,7 @@ export class ContractTemplatesService { const direction = dto.tradeDirection; const intercity = direction === "INTERCITY"; - if (intercity && dto.withCustoms !== undefined) { + if (intercity && (dto.withCustoms !== undefined || dto.ethiopianCustomsOnly)) { throw new BadRequestException( "Intercity contracts are domestic and cross no border — they have no customs clearing variant", ); @@ -112,12 +116,24 @@ export class ContractTemplatesService { ); } const withCustoms = intercity ? null : Boolean(dto.withCustoms); + const ethiopianOnly = Boolean(dto.ethiopianCustomsOnly) && !intercity; + if (ethiopianOnly && !withCustoms) { + throw new BadRequestException( + "Ethiopian-customs-only is a customs clearing variant — it requires withCustoms to be true", + ); + } - const label = this.comboLabel(cargoType.cargoTypeName, direction, withCustoms); + const label = this.comboLabel( + cargoType.cargoTypeName, + direction, + withCustoms, + ethiopianOnly, + ); const existing = await this.repository.findByCargoCombo( dto.cargoTypeId, direction, withCustoms, + ethiopianOnly, ); if (existing) { throw new ConflictException( @@ -126,11 +142,13 @@ export class ContractTemplatesService { } const template = new ContractTemplate(); - template.code = bulkTemplateCode(cargoType.code, direction, withCustoms); + template.code = bulkTemplateCode(cargoType.code, direction, withCustoms, ethiopianOnly); template.name = dto.name ?? label; template.description = dto.description ?? null; template.documentTitle = withCustoms - ? `${cargoType.cargoTypeName} Transportation and Customs Clearance Services` + ? ethiopianOnly + ? `${cargoType.cargoTypeName} Transportation and Ethiopian Customs Clearance Services` + : `${cargoType.cargoTypeName} Transportation and Customs Clearance Services` : `${cargoType.cargoTypeName} Transportation Services`; template.whereasClauses = []; template.articles = []; @@ -138,6 +156,7 @@ export class ContractTemplatesService { template.cargoTypeId = cargoType.id; template.tradeDirection = direction; template.withCustoms = withCustoms; + template.ethiopianCustomsOnly = intercity ? null : ethiopianOnly; template.isSystem = false; try { return await this.repository.saveTemplate(template); @@ -157,18 +176,21 @@ export class ContractTemplatesService { cargoTypeName: string, direction: BulkTemplateDirection, withCustoms: boolean | null, + ethiopianCustomsOnly = false, ): string { const dir = direction.charAt(0) + direction.slice(1).toLowerCase(); const customs = withCustoms === null ? "" : withCustoms - ? ", with customs clearing" + ? ethiopianCustomsOnly + ? ", with Ethiopian customs clearing only" + : ", with customs clearing" : ", without customs clearing"; return `${cargoTypeName} Bulk Contract (${dir}${customs})`; } - /** Bulk templates only — the five seeded container templates are permanent. */ + /** Bulk templates only — the seeded container templates are permanent. */ async remove(code: string): Promise { const template = await this.getByCode(code); if (template.isSystem) { @@ -193,6 +215,7 @@ export class ContractTemplatesService { freightType?: string | null, customsClearingEnabled?: boolean | null, cargoTypeId?: string | null, + ethiopianCustomsOnly?: boolean | null, ): Promise { const isBulk = (freightType ?? "").toUpperCase().includes("BULK"); if (isBulk) { @@ -202,12 +225,16 @@ export class ContractTemplatesService { cargoTypeId, direction, direction === "INTERCITY" ? null : Boolean(customsClearingEnabled), + direction === "INTERCITY" + ? false + : Boolean(customsClearingEnabled && ethiopianCustomsOnly), ); } const code = contractTemplateCodeFor( tradeDirection, freightType, customsClearingEnabled, + ethiopianCustomsOnly, ); const template = await this.repository.findByCode(code); return template?.isActive ? template : null; diff --git a/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts b/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts index 9f7579dbd..7676749f3 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts @@ -43,6 +43,14 @@ export class CreateContractTemplateDto { @IsBoolean() withCustoms?: boolean; + @ApiPropertyOptional({ + description: + "Restricts the with-customs variant to Ethiopian-side clearing only (Djibouti stays with the Client). Requires withCustoms=true; rejected for INTERCITY", + }) + @IsOptional() + @IsBoolean() + ethiopianCustomsOnly?: boolean; + @ApiPropertyOptional({ description: "Display name (derived from the cargo type when omitted)" }) @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts index e070aa1e4..f94e5d52f 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts @@ -4,9 +4,10 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; import { CargoType } from "../../rule-engine/entities/cargo-type.entity"; /** - * The five seeded container templates (import/export split by customs - * clearing; intercity is domestic, crosses no border, so it has a single - * template). These are system rows: always present, never deletable. + * The seeded container templates (import/export split by customs-clearing + * option — full, Ethiopian-only, none; intercity is domestic, crosses no + * border, so it has a single template). These are system rows: always + * present, never deletable. * * Bulk templates are NOT seeded — staff create them per bulk cargo type * (`cargoTypeId`), trade direction (`tradeDirection`) and customs option @@ -20,18 +21,24 @@ import { CargoType } from "../../rule-engine/entities/cargo-type.entity"; * * The `_CUSTOMS` variant is issued when the contract has customs clearing * enabled (the Service Provider clears in Djibouti/Ethiopia on the Client's - * behalf); `_NO_CUSTOMS` is the transport-only paper, where the Client handles + * behalf); `_ETHIOPIAN_CUSTOMS` when the service type is Ethiopian-customs-only + * (the Service Provider clears the Ethiopian side only, Djibouti stays with the + * Client); `_NO_CUSTOMS` is the transport-only paper, where the Client handles * its own declarations. */ export const CONTRACT_TEMPLATE_CODES = [ "IMPORT_BULK_CUSTOMS", + "IMPORT_BULK_ETHIOPIAN_CUSTOMS", "IMPORT_BULK_NO_CUSTOMS", "EXPORT_BULK_CUSTOMS", + "EXPORT_BULK_ETHIOPIAN_CUSTOMS", "EXPORT_BULK_NO_CUSTOMS", "INTERCITY_BULK", "IMPORT_CONTAINER_CUSTOMS", + "IMPORT_CONTAINER_ETHIOPIAN_CUSTOMS", "IMPORT_CONTAINER_NO_CUSTOMS", "EXPORT_CONTAINER_CUSTOMS", + "EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS", "EXPORT_CONTAINER_NO_CUSTOMS", "INTERCITY_CONTAINER", ] as const; @@ -66,6 +73,7 @@ export function contractTemplateCodeFor( tradeDirection?: string | null, freightType?: string | null, customsClearingEnabled?: boolean | null, + ethiopianCustomsOnly?: boolean | null, ): ContractTemplateCode { const direction = tradeDirection === "IMPORT" @@ -78,7 +86,11 @@ export function contractTemplateCodeFor( if (direction === "INTERCITY") { return `INTERCITY_${freight}` as ContractTemplateCode; } - const customs = customsClearingEnabled ? "CUSTOMS" : "NO_CUSTOMS"; + const customs = customsClearingEnabled + ? ethiopianCustomsOnly + ? "ETHIOPIAN_CUSTOMS" + : "CUSTOMS" + : "NO_CUSTOMS"; return `${direction}_${freight}_${customs}` as ContractTemplateCode; } @@ -107,9 +119,16 @@ export function bulkTemplateCode( cargoCode: string, direction: BulkTemplateDirection, withCustoms: boolean | null, + ethiopianCustomsOnly = false, ): string { const suffix = - direction === "INTERCITY" ? "" : withCustoms ? "_CUSTOMS" : "_NO_CUSTOMS"; + direction === "INTERCITY" + ? "" + : withCustoms + ? ethiopianCustomsOnly + ? "_ETHIOPIAN_CUSTOMS" + : "_CUSTOMS" + : "_NO_CUSTOMS"; return `BULK_${direction}_${cargoCode}${suffix}`.toUpperCase(); } @@ -162,7 +181,15 @@ export class ContractTemplate extends BaseEntity { @Column({ name: "with_customs", type: "boolean", nullable: true }) withCustoms?: boolean | null; - /** The five seeded container templates — cannot be deleted. */ + /** + * Bulk templates only: the with-customs variant restricted to Ethiopian-side + * clearing (Djibouti stays with the Client). Only meaningful when + * `withCustoms` is true; null/false otherwise. + */ + @Column({ name: "ethiopian_customs_only", type: "boolean", nullable: true }) + ethiopianCustomsOnly?: boolean | null; + + /** The seeded container templates — cannot be deleted. */ @Column({ name: "is_system", type: "boolean", default: false }) isSystem!: boolean; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts index bdb8d74cd..565164995 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts @@ -227,3 +227,85 @@ describe('ContractBookingService — quantity-cap completion', () => { }); }); }); + +/** + * The customer's shipment request is the order: GL may not change its container + * sizes/quantities or billing currency at completion — only per-unit details. + */ +describe('ContractBookingService — shipment-request lock at completion', () => { + type WithAssert = { + assertMatchesShipmentRequest( + bookingId: string, + dto: { + paymentCurrency?: string; + containers?: Array<{ containerSize: string; quantity: number }>; + bulkLines?: Array<{ cargoWeightTons?: number }>; + }, + ): Promise; + }; + + const serviceWithRequest = (request: unknown): WithAssert => { + const svc = Object.create(ContractBookingService.prototype) as WithAssert & { + dataSource: unknown; + }; + svc.dataSource = { + getRepository: () => ({ findOne: async () => request }), + }; + return svc; + }; + + const request = { + paymentCurrency: 'USD', + requestedLines: { + containers: [ + { containerSize: '20ft', quantity: 2 }, + { containerSize: '40ft', quantity: 1 }, + ], + }, + }; + + it('accepts the exact requested quantities and currency', async () => { + await expect( + serviceWithRequest(request).assertMatchesShipmentRequest('b1', { + paymentCurrency: 'USD', + containers: [ + { containerSize: '40ft', quantity: 1 }, + { containerSize: '20ft', quantity: 2 }, + ], + }), + ).resolves.toBeUndefined(); + }); + + it('rejects changed quantities', async () => { + await expect( + serviceWithRequest(request).assertMatchesShipmentRequest('b1', { + paymentCurrency: 'USD', + containers: [ + { containerSize: '20ft', quantity: 4 }, + { containerSize: '40ft', quantity: 1 }, + ], + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects a changed billing currency', async () => { + await expect( + serviceWithRequest(request).assertMatchesShipmentRequest('b1', { + paymentCurrency: 'ETB', + containers: [ + { containerSize: '20ft', quantity: 2 }, + { containerSize: '40ft', quantity: 1 }, + ], + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('is a no-op without a linked request', async () => { + await expect( + serviceWithRequest(null).assertMatchesShipmentRequest('b1', { + paymentCurrency: 'ETB', + containers: [{ containerSize: '20ft', quantity: 9 }], + }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 313364d8c..f5e00f503 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -11,6 +11,7 @@ import { import { DataSource } from 'typeorm'; import { OnEvent } from '@nestjs/event-emitter'; import { insertWithGeneratedReference } from '@edr/api-common'; +import { CargoUnitOfMeasure } from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; @@ -28,6 +29,7 @@ import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-s import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { eatDay } from '../train-scheduling/batch-window.util'; +import { bulkTonsPerWagon } from '../train-scheduling/train-capacity.util'; import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RuleEngineService } from '../rule-engine/rule-engine.service'; @@ -36,6 +38,7 @@ import { CargoType } from '../rule-engine/entities/cargo-type.entity'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { hasFreightPermission } from '../../common/freight-permission.util'; +import { BookingRequest } from './entities/booking-request.entity'; import { Contract } from './entities/contract.entity'; import { ContractRoute } from './entities/contract-route.entity'; import { @@ -272,6 +275,8 @@ export class ContractBookingService { }); } + const bulkFields = await this.resolveBulkCargoFields(contract, dto); + // Denormalize route/direction/freight onto the booking for the scheduling engine. // Retry past a concurrent insert that grabbed the same BK sequence number. const booking = await insertWithGeneratedReference( @@ -305,8 +310,7 @@ export class ContractBookingService { cargoFreeText: dto.cargoFreeText?.trim() || null, isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'), isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'), - cargoTotalWeightVgm: this.resolveBulkTons(dto), - bulkTotalWeightTons: this.resolveBulkWeightTons(dto), + ...bulkFields, firstMilePickupAddress: contract.firstMilePickupAddress ?? null, firstMilePickupLat: contract.firstMilePickupLat ?? null, firstMilePickupLng: contract.firstMilePickupLng ?? null, @@ -395,6 +399,10 @@ export class ContractBookingService { if ( withContainers && freightType === 'CONTAINER' && + // A rebooked cancellation credit carries `skipAutoConsolidation`: its + // shared-wagon partner is picked by GL in the rebook flow, so nothing may + // auto-claim (or park) it here behind GL's back. + !dto.skipAutoConsolidation && (await this.consolidationService.needsConsolidationFromBooking( withContainers, )) @@ -848,6 +856,35 @@ export class ContractBookingService { if (!dto.scheduledDate) { throw new BadRequestException('A binding shipment day is required'); } + // Without-customs import/export: the customer's own clearing agent (name, + // email, phone) is captured per booking at completion. A resubmit may omit + // the fields and keep what the booking already stored. Customs contracts + // (GL clears) and intercity (no border) never collect an agent. + if ( + !contract.customsClearingEnabled && + contract.tradeDirection !== 'DOMESTIC' + ) { + const agentName = + dto.customsClearingAgent?.trim() || booking.customsClearingAgent || null; + const agentEmail = + dto.customsClearingAgentEmail?.trim() || + booking.customsClearingAgentEmail || + null; + const agentPhone = + dto.customsClearingAgentPhone?.trim() || + booking.customsClearingAgentPhone || + null; + if (!agentName || !agentEmail || !agentPhone) { + throw new BadRequestException( + 'Customs clearing agent name, email and phone are required to complete this booking.', + ); + } + await this.bookingsRepository.update(booking.id, { + customsClearingAgent: agentName, + customsClearingAgentEmail: agentEmail, + customsClearingAgentPhone: agentPhone, + } as never); + } // No expiry gate here on purpose: this booking was already initiated // before the contract lapsed (createUnderContract/initiateUnderContract // already checked expiry at start). Finishing an in-flight booking must @@ -863,6 +900,12 @@ export class ContractBookingService { direction: contract.tradeDirection ?? null, }); + // The customer's shipment request is the order: sizes, quantities and + // billing currency are theirs — GL enters everything else. Both halves of a + // consolidated pair pass through here, so each is checked against its OWN + // request. + await this.assertMatchesShipmentRequest(booking.id, dto); + const freightType = contract.freightType; let hasCargo = (booking.bookingContainers?.length ?? 0) > 0 || @@ -944,8 +987,7 @@ export class ContractBookingService { await this.bookingsRepository.update(booking.id, { cargoTypeId: this.resolveCargoTypeId(contract, dto), cargoFreeText: dto.cargoFreeText?.trim() || null, - cargoTotalWeightVgm: this.resolveBulkTons(dto), - bulkTotalWeightTons: this.resolveBulkWeightTons(dto), + ...(await this.resolveBulkCargoFields(contract, dto)), equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto), // Completion is where the cargo — and therefore the price — is fixed, so // it is also where the billing currency is chosen. A bare instance was @@ -1052,6 +1094,72 @@ export class ContractBookingService { return { booking: completed, warnings }; } + /** + * The linked shipment request (customs Path B) is the customer's order: + * container sizes + quantities and the billing currency are the customer's + * choices, and GL may not change them at completion — only per-unit details + * (numbers, seals, VGM, handling) are GL's to enter. No linked request, or a + * legacy request without lines/currency ⇒ nothing to enforce. Container lines + * are checked only when the payload restates cargo (a day-only resubmit keeps + * the already-validated persisted cargo). + */ + private async assertMatchesShipmentRequest( + bookingId: string, + dto: CreateBookingUnderContractDto, + ): Promise { + const request = await this.dataSource.getRepository(BookingRequest).findOne({ + where: { createdBookingId: bookingId }, + }); + if (!request) return; + const lines = request.requestedLines ?? {}; + + if (request.paymentCurrency) { + if (dto.paymentCurrency && dto.paymentCurrency !== request.paymentCurrency) { + throw new BadRequestException( + `The customer chose ${request.paymentCurrency} on the shipment request — the billing currency cannot be changed.`, + ); + } + dto.paymentCurrency = request.paymentCurrency; + } + + if (dto.containers?.length && lines.containers?.length) { + // Compare per size in ft ("20ft" vs "20FT"/"20" spellings must not differ). + const byFt = (rows: Array<{ containerSize: string; quantity: number }>) => { + const map = new Map(); + for (const row of rows) { + const ft = parseInt(String(row.containerSize), 10); + map.set(ft, (map.get(ft) ?? 0) + Number(row.quantity || 0)); + } + return map; + }; + const requested = byFt(lines.containers); + const given = byFt(dto.containers); + const same = + requested.size === given.size && + [...requested].every(([ft, qty]) => given.get(ft) === qty); + if (!same) { + const summary = [...requested] + .map(([ft, qty]) => `${qty} × ${ft}ft`) + .join(', '); + throw new BadRequestException( + `The customer requested exactly ${summary} — container sizes and quantities cannot be changed at completion.`, + ); + } + } + + if (dto.bulkLines?.length && lines.bulk?.cargoWeightTons != null) { + const givenTons = dto.bulkLines.reduce( + (sum, l) => sum + Number(l.cargoWeightTons || 0), + 0, + ); + if (givenTons !== Number(lines.bulk.cargoWeightTons)) { + throw new BadRequestException( + `The customer requested ${lines.bulk.cargoWeightTons} tons on the shipment request — the bulk quantity cannot be changed at completion.`, + ); + } + } + } + /** * Search for a complementary partner for a parked-eligible drawdown, pair it or * park it in PENDING_CONSOLIDATION with the resume status it should return to. @@ -1456,8 +1564,10 @@ export class ContractBookingService { return probe; } - probe.cargoTotalWeightVgm = this.resolveBulkTons(dto); - probe.bulkTotalWeightTons = this.resolveBulkWeightTons(dto); + const bulkFields = await this.resolveBulkCargoFields(contract, dto); + probe.cargoTotalWeightVgm = bulkFields.cargoTotalWeightVgm; + probe.bulkTotalWeightTons = bulkFields.bulkTotalWeightTons; + probe.bulkRequestedWagons = bulkFields.bulkRequestedWagons; const cargoTypeId = this.resolveCargoTypeId(contract, dto); probe.cargoTypeId = cargoTypeId; if (cargoTypeId) { @@ -1861,6 +1971,107 @@ export class ContractBookingService { return tons > 0 ? tons : null; } + /** + * Bulk cargo columns for the booking row, resolved against the commodity's + * unit of measure: + * + * - PER_TON: `cargoTotalWeightVgm` = tons (legacy behaviour). + * - PER_ITEM: `cargoTotalWeightVgm` = item count, real tonnage in + * `bulkTotalWeightTons` (legacy behaviour). + * - NUMBER_OF_WAGONS: `cargoTotalWeightVgm` = tons, and the payload must fix + * the wagon count (customer on the portal, GL in the backoffice). The + * count is validated so each wagon's even share (tons ÷ wagons) fits what + * one wagon of this cargo may carry; the optional item count is stored as + * information only and never prices or sizes anything. + * + * Container contracts (and payloads without bulk lines) pass through with + * the legacy zero/null values. + */ + private async resolveBulkCargoFields( + contract: Contract, + dto: CreateBookingUnderContractDto, + ): Promise<{ + cargoTotalWeightVgm: number; + bulkTotalWeightTons: number | null; + bulkRequestedWagons: number | null; + bulkItemCount: number | null; + }> { + const legacy = { + cargoTotalWeightVgm: this.resolveBulkTons(dto), + bulkTotalWeightTons: this.resolveBulkWeightTons(dto), + bulkRequestedWagons: null as number | null, + bulkItemCount: null as number | null, + }; + if (contract.freightType === 'CONTAINER' || !dto.bulkLines?.length) { + return legacy; + } + const cargoTypeId = this.resolveCargoTypeId(contract, dto); + if (!cargoTypeId) return legacy; + const cargoType = await this.dataSource.getRepository(CargoType).findOne({ + where: { id: cargoTypeId }, + relations: { wagonTypes: true }, + }); + if (cargoType?.unitOfMeasure !== CargoUnitOfMeasure.NumberOfWagons) { + return legacy; + } + + const tons = dto.bulkLines.reduce( + (sum, l) => sum + Number(l.cargoWeightTons ?? 0), + 0, + ); + const items = dto.bulkLines.reduce( + (sum, l) => sum + Number(l.itemCount ?? 0), + 0, + ); + const wagons = Math.floor(Number(dto.requestedWagons ?? 0)); + if (!(wagons >= 1)) { + throw new BadRequestException( + `${cargoType.cargoTypeName} is booked by wagons — enter the number of wagons needed.`, + ); + } + if (!(tons > 0)) { + throw new BadRequestException('Cargo weight in tons is required.'); + } + this.assertWagonShareFits(cargoType, tons, wagons); + return { + cargoTotalWeightVgm: tons, + bulkTotalWeightTons: null, + bulkRequestedWagons: wagons, + bulkItemCount: items > 0 ? Math.floor(items) : null, + }; + } + + /** + * NUMBER_OF_WAGONS: block the booking outright when the even per-wagon share + * (tons ÷ requested wagons) is heavier than what ANY of the cargo's allowed + * wagon types may carry — 100T on 2 wagons is 50T each and fine on a 60T + * wagon, but 100T on 1 wagon can never ride. Cargo types with no wagon types + * configured skip the check (allocation falls back to the default rating). + */ + private assertWagonShareFits( + cargoType: CargoType, + tons: number, + wagons: number, + ): void { + const allowed = (cargoType.wagonTypes ?? []).filter( + (wt) => Number(wt.capacityTons) > 0, + ); + if (!allowed.length) return; + const maxPerWagon = Math.max( + ...allowed.map((wt) => + bulkTonsPerWagon(cargoType, wt.id, Number(wt.capacityTons)), + ), + ); + const share = tons / wagons; + if (share > maxPerWagon) { + throw new BadRequestException( + `${tons} tons across ${wagons} wagon(s) loads ${round3(share)}T per wagon, ` + + `but a wagon of this cargo carries at most ${round3(maxPerWagon)}T — ` + + `request at least ${Math.ceil(tons / maxPerWagon)} wagons.`, + ); + } + } + /** * Per-line handling counts. Each physical container carries its own hazardous * / reefer / return switch (entered next to its VGM), so the count is however @@ -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 }) => diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 48c90fcbc..936176f86 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -428,6 +428,8 @@ export class ContractTransitionService { contract.customsClearingEnabled, // Bulk templates are keyed by the contract's cargo type. (contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId, + // Ethiopian-customs-only service types resolve to the Ethiopian variant. + contract.serviceType?.includesEthiopianCustomsOnly, ); if (!active) return null; return { diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index f50ca9d4d..0592dafc4 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -4,6 +4,7 @@ import { IsArray, IsBoolean, IsDateString, + IsEmail, IsIn, IsInt, IsNumber, @@ -11,6 +12,7 @@ import { IsString, IsUUID, Matches, + MaxLength, Min, ValidateNested, } from 'class-validator'; @@ -208,6 +210,19 @@ export class CreateBookingUnderContractDto { @Type(() => CreateBulkLineDto) bulkLines?: CreateBulkLineDto[]; + @ApiPropertyOptional({ + minimum: 1, + description: + 'NUMBER_OF_WAGONS bulk cargo only: how many wagons the shipment needs. ' + + 'The weight spreads evenly across them; a PER_WAGON rate bills this count. ' + + 'Required when the cargo type is measured by wagons, ignored otherwise.', + }) + @IsOptional() + @IsInt() + @Min(1) + @Transform(({ value }) => (value == null || value === '' ? undefined : Number(value))) + requestedWagons?: number; + @ApiPropertyOptional({ description: 'What the containers carry — captured per booking (container freight).', }) @@ -215,6 +230,29 @@ export class CreateBookingUnderContractDto { @IsString() cargoFreeText?: string; + @ApiPropertyOptional({ + maxLength: 200, + description: + 'Customs clearing agent name. Required at completion of a without-customs ' + + 'import/export booking (the service enforces it); ignored on customs contracts.', + }) + @IsOptional() + @IsString() + @MaxLength(200) + customsClearingAgent?: string; + + @ApiPropertyOptional({ maxLength: 200, description: 'Customs clearing agent email.' }) + @IsOptional() + @IsEmail() + @MaxLength(200) + customsClearingAgentEmail?: string; + + @ApiPropertyOptional({ maxLength: 50, description: 'Customs clearing agent phone number.' }) + @IsOptional() + @IsString() + @MaxLength(50) + customsClearingAgentPhone?: string; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/contracts/shipment-preview-parity.spec.ts b/apps/edr-freight-api/src/modules/contracts/shipment-preview-parity.spec.ts index 4152ffb30..d493147b9 100644 --- a/apps/edr-freight-api/src/modules/contracts/shipment-preview-parity.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/shipment-preview-parity.spec.ts @@ -63,6 +63,12 @@ describe('shipment preview / created booking parity', () => { resolveShipmentEquipmentReturn: () => c.equipmentReturn, resolveBulkTons: () => 0, resolveBulkWeightTons: () => 0, + resolveBulkCargoFields: async () => ({ + cargoTotalWeightVgm: 0, + bulkTotalWeightTons: null, + bulkRequestedWagons: null, + bulkItemCount: null, + }), resolveContainerTypeForSize: async () => ({ id: 'ct40', sizeFt: 40 }), handlingCounts: () => ({ hazardousQuantity: 0, diff --git a/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts index be3e3aa8e..7afe644a1 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/exports/exports.controller.ts b/apps/edr-freight-api/src/modules/exports/exports.controller.ts index 89ca7419a..aea35a9bc 100644 --- a/apps/edr-freight-api/src/modules/exports/exports.controller.ts +++ b/apps/edr-freight-api/src/modules/exports/exports.controller.ts @@ -3,7 +3,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { CurrentUser } from '@edr/api-common'; -import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { FreightJwtGuard } from '../../common/freight-jwt.guard'; import type { Response } from 'express'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; @@ -57,7 +57,7 @@ const toCatalogEntry = (dataset: ExportDataset): ExportCatalogEntry => ({ @ApiTags('Exports') @ApiBearerAuth() @Controller('exports') -@UseGuards(JwtGuard) +@UseGuards(FreightJwtGuard) export class ExportsController { constructor( private readonly runner: ExportRunnerService, diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts index 6fb82c3a0..16026cc96 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts @@ -10,7 +10,7 @@ import { UseGuards, } from "@nestjs/common"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; -import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard"; +import { FreightJwtGuard } from "../../common/freight-jwt.guard"; import { AuthUserPayload, @@ -21,7 +21,7 @@ import { NotificationInboxService } from "./notification-inbox.service"; @ApiTags("notifications") @ApiBearerAuth() -@UseGuards(JwtGuard) +@UseGuards(FreightJwtGuard) @Controller("notifications") export class NotificationInboxController { constructor(private readonly service: NotificationInboxService) {} diff --git a/apps/edr-freight-api/src/modules/overview/overview.repository.ts b/apps/edr-freight-api/src/modules/overview/overview.repository.ts index ec258b274..6908498bb 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.repository.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.repository.ts @@ -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 diff --git a/apps/edr-freight-api/src/modules/reports/definitions/aging-receivables.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/aging-receivables.report.ts index d5709508c..2cdc08685 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/aging-receivables.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/aging-receivables.report.ts @@ -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 { // 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) }, ]; }, diff --git a/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts index 5fe6ba81b..ffb138c22 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/charged-vs-actual-volume.report.ts @@ -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 { - const qb = allocationLedgerQb(ctx); - applyCategoryFilter(qb, ctx.params); + const qb = allocationLedgerQb(ctx, { includeEmptyWagons: true }); + applyCategoryFilter(qb, ctx.params, CATEGORY_EXPR); return qb; } @@ -89,20 +111,25 @@ export const chargedVsActualVolumeReport: ReportDefinition = { 'corridor. Charged volume is the standard weight capacity — 20 and 40 tons per laden ' + 'container, 2.24 and 3.88 empty, 70 tons per wagon of steel or fertilizer, 38 for ' + 'perishables — all editable in Operating standards. Actual volume is what the ' + - 'marshalling recorded. Volumes and wagon counts belong to the train, not to the leg, ' + - 'so they repeat on every leg it ran and across its cargo types rather than being split ' + - 'between them — the KPIs above count each train once. Ton/Km and Vehicle-Km are the ' + - 'exception and are the leg’s own, so they add up across legs into the real corridor ' + - 'figure.', + 'marshalling recorded. Cargo types are the revenue categories the money side bills ' + + 'against, so a corridor’s tonnage and its revenue read in the same buckets; wagons ' + + 'that carried nothing are their own “Empty wagon” line. Volumes and wagon counts ' + + 'belong to the train, not to the leg, so they repeat on every leg it ran rather than ' + + 'being split between them — the KPIs above count each train once. Ton/Km and ' + + 'Vehicle-Km are the exception and are the leg’s own, so they add up across legs into ' + + 'the real corridor figure.', group: 'Operations', - filters: [...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER], + filters: [...OPERATIONS_FILTERS, REVENUE_CARGO_FILTER], columns: [ { key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' }, { key: 'departedAt', label: 'Departure', type: 'date', sortable: true, sortExpr: 'ts.scheduled_departure_date' }, - { key: 'leg', label: 'Leg', type: 'string' }, - { key: 'category', label: 'Cargo type', type: 'string', sortable: true, sortExpr: CARGO_CATEGORY_EXPR }, + { key: 'legFrom', label: 'From', type: 'string', sortable: true, sortExpr: 'COALESCE(lfy.label, lfy.code)' }, + { key: 'legTo', label: 'To', type: 'string', sortable: true, sortExpr: 'COALESCE(lty.label, lty.code)' }, + { key: 'category', label: 'Cargo type', type: 'string', sortable: true, sortExpr: CATEGORY_EXPR }, { key: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true }, { key: 'actualTons', label: 'Actual volume', type: 'tons', sortable: true }, + { key: 'containers20', label: '20ft', type: 'number', sortable: true }, + { key: 'containers40', label: '40ft', type: 'number', sortable: true }, { key: 'teu', label: 'TEU', type: 'number' }, { key: 'wagons', label: 'Loaded wagons', type: 'number' }, { key: 'emptyWagons', label: 'Empty wagons', type: 'number' }, @@ -116,10 +143,13 @@ export const chargedVsActualVolumeReport: ReportDefinition = { return legQuery(ctx) .select("COALESCE(ts.train_number, '—')", 'trainNumber') .addSelect(`to_char(COALESCE(ts.actual_departure_at, ts.scheduled_departure_date), 'YYYY-MM-DD HH24:MI')`, 'departedAt') - .addSelect("COALESCE(lfy.label, lfy.code, '?') || ' → ' || COALESCE(lty.label, lty.code, '?')", 'leg') - .addSelect(CARGO_CATEGORY_LABEL_EXPR, 'category') + .addSelect("COALESCE(lfy.label, lfy.code, '?')", 'legFrom') + .addSelect("COALESCE(lty.label, lty.code, '?')", 'legTo') + .addSelect(CATEGORY_LABEL_EXPR, 'category') .addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 2)::float8`, 'chargedTons') .addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 2)::float8`, 'actualTons') + .addSelect(`COALESCE(SUM(${ALLOC_CONTAINERS_20}), 0)::int`, 'containers20') + .addSelect(`COALESCE(SUM(${ALLOC_CONTAINERS_40}), 0)::int`, 'containers40') .addSelect(TEU_EXPR, 'teu') .addSelect(LOADED_WAGONS_EXPR, 'wagons') .addSelect(`${EMPTY_WAGONS}::int`, 'emptyWagons') @@ -137,7 +167,7 @@ export const chargedVsActualVolumeReport: ReportDefinition = { .addGroupBy('lfy.code') .addGroupBy('lty.label') .addGroupBy('lty.code') - .addGroupBy(CARGO_CATEGORY_EXPR); + .addGroupBy(CATEGORY_EXPR); }, async summary(ctx) { const row = await baseQuery(ctx) diff --git a/apps/edr-freight-api/src/modules/reports/definitions/invoicing-pipeline.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/invoicing-pipeline.report.ts index 8907f4f5a..86b3db29d 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/invoicing-pipeline.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/invoicing-pipeline.report.ts @@ -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 { - 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), + }, ]; }, }; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/loading-unloading.report.spec.ts b/apps/edr-freight-api/src/modules/reports/definitions/loading-unloading.report.spec.ts new file mode 100644 index 000000000..b5b72a06b --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/loading-unloading.report.spec.ts @@ -0,0 +1,30 @@ +import { visibleColumns } from '../report-runner.service'; +import { loadingUnloadingReport as def } from './loading-unloading.report'; + +/** + * The two grains select different columns — per train the stop's own times, + * per station the averages over it. A column shown under a grain its query + * doesn't select is a blank column; a column SORTED under one is a 42703. + */ +describe('loading-unloading', () => { + const keys = (grain: string) => visibleColumns(def, { grain }).map((c) => c.key); + + it('shows the stop times per train and the averages per station, never both', () => { + expect(keys('train')).toEqual(expect.arrayContaining(['arrivedAt', 'loadUnloadHours'])); + expect(keys('train')).not.toEqual(expect.arrayContaining(['avgLoadUnloadHours', 'stops'])); + expect(keys('station')).toEqual(expect.arrayContaining(['avgLoadUnloadHours', 'stops'])); + expect(keys('station')).not.toEqual(expect.arrayContaining(['arrivedAt', 'trainNumber'])); + }); + + it('sorts by a column both grains select, so the default sort never 42703s', () => { + for (const grain of ['train', 'station']) { + expect(keys(grain)).toContain(def.defaultSort!.key); + } + }); + + it("defaults the grain, so an unset filter can't show the wrong half", () => { + const grain = def.filters.find((f) => f.key === 'grain')!.defaultValue; + expect(grain).toBe('train'); + expect(keys(grain!)).toEqual(keys('train')); + }); +}); diff --git a/apps/edr-freight-api/src/modules/reports/definitions/loading-unloading.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/loading-unloading.report.ts index 753f71d3a..aa7564b39 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/loading-unloading.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/loading-unloading.report.ts @@ -6,8 +6,10 @@ import { cycleRateExpr, handlingHours, hoursBetween, + loadingEnd, loadingHours, loadingSource, + loadingStart, otherActivityHours, stationStaysQb, unloadingHours, @@ -18,9 +20,12 @@ import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-class * Loading and unloading per train — the spec's own report format: train number, * total loading and unloading time, other activity, station staying time. * - * The staying-time report publishes one row per individual stop; this one rolls - * a train's stops up into the chosen period, which is what "for week report, - * calculate average in the week" asks for. The station stays in the grain + * Two shapes, one definition. Per train the row is the stop itself: the logged + * arrival, departure, unloading and loading times and that stop's own + * durations, because the train number is what makes a specific stop worth + * naming. Per station it rolls up into the chosen period — one row per station, + * averaged over every train that called there, which is what "for week report, + * calculate average in the week" asks for. The station stays in both grains * because a train works both ends of the corridor and the standard it is judged * against differs by side (10h Ethiopia, 13h Djibouti) — averaging a train's * Nagad and Gelan stops together would compare that mixture to one standard. @@ -56,12 +61,20 @@ const GRAIN_FILTER: ReportFilterDef = { key: 'grain', label: 'Group by', type: 'select', + defaultValue: 'train', options: [ { value: 'train', label: 'Train' }, { value: 'station', label: 'Station' }, ], }; +/** Per train the rows are stops, so they carry times; per station, averages. */ +const TRAIN_ONLY = { grain: 'station' }; +const STATION_ONLY = { grain: 'train' }; + +/** Same display as the staying-time report, so a stop reads alike in both. */ +const at = (expr: string): string => `to_char(${expr}, 'YYYY-MM-DD HH24:MI')`; + /** Whitelisted here, so the user's value never reaches SQL. */ const byStation = (ctx: ReportContext): boolean => ctx.params.grain === 'station'; @@ -69,9 +82,10 @@ export const loadingUnloadingReport: ReportDefinition = { key: 'loading-unloading', title: 'Loading & Unloading', description: - 'Loading and unloading per train, at the granularity you choose — one row per train per ' + - 'station per period, which at week or month grain is that train’s average over its stops ' + - 'in the period, the way the OCC report publishes it. Total loading and unloading is ' + + 'Loading and unloading, at the granularity you choose. Grouped by Train the row is one ' + + 'stop — its logged arrival, departure, unloading and loading times and that stop’s own ' + + 'durations. Grouped by Station it is one row per station per period, averaged over every ' + + 'train that called there, the way the OCC report publishes it. Total loading and unloading is ' + 'the stop’s handling window, unloading start to loading end, which is the container ' + 'measure; the unloading and loading columns split it for bulk stations that only do ' + 'one of the two (Nagad, BCC and DMP on the Djibouti side; Sebeta, GMP, Adama and Modjo ' + @@ -95,74 +109,241 @@ export const loadingUnloadingReport: ReportDefinition = { ], columns: [ { key: 'period', label: 'Period', type: 'string', sortable: true }, - { key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true }, + { + key: 'trainNumber', + label: 'Train No.', + type: 'string', + sortable: true, + hideWhen: TRAIN_ONLY, + }, { key: 'station', label: 'Station', type: 'string', sortable: true }, { key: 'country', label: 'Country', type: 'string' }, - { key: 'trainType', label: 'Train type', type: 'string' }, - { key: 'stops', label: 'Stops', type: 'number', sortable: true }, - { key: 'handlingMeasured', label: 'Handling measured', type: 'number' }, + // Per station this would be a MAX over whatever mix of trains called there. + { + key: 'trainType', + label: 'Train type', + type: 'string', + hideWhen: TRAIN_ONLY, + }, + { + key: 'stops', + label: 'Stops', + type: 'number', + sortable: true, + hideWhen: STATION_ONLY, + }, + { + key: 'handlingMeasured', + label: 'Handling measured', + type: 'number', + hideWhen: STATION_ONLY, + }, { key: 'loadingSource', label: 'Loading from', type: 'string' }, - { key: 'avgUnloadingHours', label: 'Avg unloading (hrs)', type: 'number', sortable: true }, - { key: 'avgLoadingHours', label: 'Avg loading (hrs)', type: 'number', sortable: true }, + // Per train: this stop's own clock, not a mean of several. + { + key: 'arrivedAt', + label: 'Arrived', + type: 'date', + sortable: true, + hideWhen: TRAIN_ONLY, + }, + { + key: 'departedAt', + label: 'Departed', + type: 'date', + hideWhen: TRAIN_ONLY, + }, + { + key: 'unloadingStartedAt', + label: 'Unloading start', + type: 'date', + hideWhen: TRAIN_ONLY, + }, + { + key: 'unloadingCompletedAt', + label: 'Unloading end', + type: 'date', + hideWhen: TRAIN_ONLY, + }, + { + key: 'loadingStartedAt', + label: 'Loading start', + type: 'date', + hideWhen: TRAIN_ONLY, + }, + { + key: 'loadingCompletedAt', + label: 'Loading end', + type: 'date', + hideWhen: TRAIN_ONLY, + }, + { + key: 'unloadingHours', + label: 'Unloading (hrs)', + type: 'number', + sortable: true, + hideWhen: TRAIN_ONLY, + }, + { + key: 'loadingHours', + label: 'Loading (hrs)', + type: 'number', + sortable: true, + hideWhen: TRAIN_ONLY, + }, + { + key: 'loadUnloadHours', + label: 'Loading + unloading (hrs)', + type: 'number', + sortable: true, + hideWhen: TRAIN_ONLY, + }, + { + key: 'otherActivityHours', + label: 'Other activity (hrs)', + type: 'number', + hideWhen: TRAIN_ONLY, + }, + { + key: 'stayingHours', + label: 'Staying (hrs)', + type: 'number', + sortable: true, + hideWhen: TRAIN_ONLY, + }, + { + key: 'avgUnloadingHours', + label: 'Avg unloading (hrs)', + type: 'number', + sortable: true, + hideWhen: STATION_ONLY, + }, + { + key: 'avgLoadingHours', + label: 'Avg loading (hrs)', + type: 'number', + sortable: true, + hideWhen: STATION_ONLY, + }, { key: 'avgLoadUnloadHours', label: 'Avg loading + unloading (hrs)', type: 'number', sortable: true, + hideWhen: STATION_ONLY, + }, + { + key: 'avgOtherActivityHours', + label: 'Avg other activity (hrs)', + type: 'number', + hideWhen: STATION_ONLY, + }, + { + key: 'avgStayingHours', + label: 'Avg staying (hrs)', + type: 'number', + sortable: true, + hideWhen: STATION_ONLY, + }, + { + key: 'stayStandardHours', + label: 'Staying standard (hrs)', + type: 'number', }, - { key: 'avgOtherActivityHours', label: 'Avg other activity (hrs)', type: 'number' }, - { key: 'avgStayingHours', label: 'Avg staying (hrs)', type: 'number', sortable: true }, - { key: 'stayStandardHours', label: 'Staying standard (hrs)', type: 'number' }, { key: 'stayVerdict', label: 'Staying verdict', type: 'string' }, - { key: 'handlingStandardHours', label: 'Handling standard (hrs)', type: 'number' }, - { key: 'handlingRate', label: 'Handling rate', type: 'percent', sortable: true }, + { + key: 'handlingStandardHours', + label: 'Handling standard (hrs)', + type: 'number', + }, + { + key: 'handlingRate', + label: 'Handling rate', + type: 'percent', + sortable: true, + }, ], defaultSort: { key: 'period', dir: 'DESC' }, - chart: { type: 'bar', x: 'trainNumber', y: ['avgLoadUnloadHours'] }, + // Only plottable at station grain — per train the rows are individual stops, + // and the frontend drops the chart toggle when its columns are hidden. + chart: { type: 'bar', x: 'station', y: ['avgLoadUnloadHours'] }, query(ctx) { const { params } = ctx; + + // Per train the row IS the stop: its own logged times and its own + // durations, since an average of one stop is just the stop with the clock + // thrown away. Averaging starts where the grain stops naming the train. + if (!byStation(ctx)) { + return stationStaysQb(ctx) + .select(periodExprOn('s.arrived_at', params), 'period') + .addSelect(TRAIN_NUMBER, 'trainNumber') + .addSelect('s.station', 'station') + .addSelect('s.country', 'country') + .addSelect('s.train_type', 'trainType') + .addSelect(loadingSource('s'), 'loadingSource') + .addSelect(at('s.arrived_at'), 'arrivedAt') + .addSelect(at('s.departed_at'), 'departedAt') + .addSelect(at('s.unloading_started_at'), 'unloadingStartedAt') + .addSelect(at('s.unloading_completed_at'), 'unloadingCompletedAt') + .addSelect(at(loadingStart('s')), 'loadingStartedAt') + .addSelect(at(loadingEnd('s')), 'loadingCompletedAt') + .addSelect(unloadingHours('s'), 'unloadingHours') + .addSelect(loadingHours('s'), 'loadingHours') + .addSelect(HANDLING_HOURS, 'loadUnloadHours') + .addSelect(OTHER_ACTIVITY_HOURS, 'otherActivityHours') + .addSelect(STAYING_HOURS, 'stayingHours') + .addSelect('s.standard_hours::float8', 'stayStandardHours') + .addSelect( + `CASE WHEN (${STAYING_HOURS})::numeric <= s.standard_hours + THEN 'Encouraging' ELSE 'Needs reason' END`, + 'stayVerdict', + ) + .addSelect('s.handling_standard_hours::float8', 'handlingStandardHours') + .addSelect( + cycleRateExpr(`(${HANDLING_HOURS})::numeric`, 's.handling_standard_hours'), + 'handlingRate', + ); + } + // Reused verbatim in the GROUP BY, per the trap documented on `periodExpr`. const bucket = periodTruncExprOn('s.arrived_at', params); - const perStation = byStation(ctx); - const qb = stationStaysQb(ctx) - .select(periodExprOn('s.arrived_at', params), 'period') - .addSelect(perStation ? "'All trains'" : TRAIN_NUMBER, 'trainNumber') - .addSelect('s.station', 'station') - .addSelect('s.country', 'country') - .addSelect('MAX(s.train_type)', 'trainType') - .addSelect('COUNT(*)::int', 'stops') - .addSelect(`COUNT(${HANDLING_HOURS})::int`, 'handlingMeasured') - // Which side of the COALESCE the loading columns came from. A group that - // mixes both says so rather than claiming either. - .addSelect( - `CASE WHEN COUNT(DISTINCT ${loadingSource('s')}) > 1 THEN 'Mixed' + return ( + stationStaysQb(ctx) + .select(periodExprOn('s.arrived_at', params), 'period') + .addSelect('s.station', 'station') + .addSelect('s.country', 'country') + .addSelect('COUNT(*)::int', 'stops') + .addSelect(`COUNT(${HANDLING_HOURS})::int`, 'handlingMeasured') + // Which side of the COALESCE the loading columns came from. A group that + // mixes both says so rather than claiming either. + .addSelect( + `CASE WHEN COUNT(DISTINCT ${loadingSource('s')}) > 1 THEN 'Mixed' ELSE MAX(${loadingSource('s')}) END`, - 'loadingSource', - ) - .addSelect(avg(unloadingHours('s')), 'avgUnloadingHours') - .addSelect(avg(loadingHours('s')), 'avgLoadingHours') - .addSelect(avg(HANDLING_HOURS), 'avgLoadUnloadHours') - .addSelect(avg(OTHER_ACTIVITY_HOURS), 'avgOtherActivityHours') - .addSelect(avg(STAYING_HOURS), 'avgStayingHours') - .addSelect('MAX(s.standard_hours)::float8', 'stayStandardHours') - .addSelect( - `CASE WHEN AVG((${STAYING_HOURS})::numeric) <= MAX(s.standard_hours) + 'loadingSource', + ) + .addSelect(avg(unloadingHours('s')), 'avgUnloadingHours') + .addSelect(avg(loadingHours('s')), 'avgLoadingHours') + .addSelect(avg(HANDLING_HOURS), 'avgLoadUnloadHours') + .addSelect(avg(OTHER_ACTIVITY_HOURS), 'avgOtherActivityHours') + .addSelect(avg(STAYING_HOURS), 'avgStayingHours') + .addSelect('MAX(s.standard_hours)::float8', 'stayStandardHours') + .addSelect( + `CASE WHEN AVG((${STAYING_HOURS})::numeric) <= MAX(s.standard_hours) THEN 'Encouraging' ELSE 'Needs reason' END`, - 'stayVerdict', - ) - .addSelect(`${HANDLING_STANDARD}::float8`, 'handlingStandardHours') - // Same formula the turnaround cycle publishes, so the two read alike. - // NULL standard in, NULL rate out — nothing to measure against yet. - .addSelect( - cycleRateExpr(`AVG((${HANDLING_HOURS})::numeric)`, HANDLING_STANDARD), - 'handlingRate', - ) - .groupBy(bucket) - .addGroupBy('s.station') - .addGroupBy('s.country'); - if (!perStation) qb.addGroupBy(TRAIN_NUMBER); - return qb; + 'stayVerdict', + ) + .addSelect(`${HANDLING_STANDARD}::float8`, 'handlingStandardHours') + // Same formula the turnaround cycle publishes, so the two read alike. + // NULL standard in, NULL rate out — nothing to measure against yet. + .addSelect( + cycleRateExpr(`AVG((${HANDLING_HOURS})::numeric)`, HANDLING_STANDARD), + 'handlingRate', + ) + .groupBy(bucket) + .addGroupBy('s.station') + .addGroupBy('s.country') + ); }, async summary(ctx) { const row = await stationStaysQb(ctx) @@ -170,13 +351,26 @@ export const loadingUnloadingReport: ReportDefinition = { .addSelect(`COUNT(${HANDLING_HOURS})::int`, 'measured') .addSelect(avg(HANDLING_HOURS), 'avgHandling') .addSelect(avg(OTHER_ACTIVITY_HOURS), 'avgOther') - .getRawOne<{ stops: number; measured: number; avgHandling: number; avgOther: number }>(); + .getRawOne<{ + stops: number; + measured: number; + avgHandling: number; + avgOther: number; + }>(); return [ { label: 'Stops measured', value: Number(row?.stops ?? 0) }, { label: 'Handling measured', value: Number(row?.measured ?? 0) }, - { label: 'Average loading + unloading', value: Number(row?.avgHandling ?? 0), unit: 'h' }, - { label: 'Average other activity', value: Number(row?.avgOther ?? 0), unit: 'h' }, + { + label: 'Average loading + unloading', + value: Number(row?.avgHandling ?? 0), + unit: 'h', + }, + { + label: 'Average other activity', + value: Number(row?.avgOther ?? 0), + unit: 'h', + }, ]; }, }; diff --git a/apps/edr-freight-api/src/modules/reports/operations-classification.spec.ts b/apps/edr-freight-api/src/modules/reports/operations-classification.spec.ts index 228e24470..9dd31cdfd 100644 --- a/apps/edr-freight-api/src/modules/reports/operations-classification.spec.ts +++ b/apps/edr-freight-api/src/modules/reports/operations-classification.spec.ts @@ -2,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)); diff --git a/apps/edr-freight-api/src/modules/reports/operations-classification.ts b/apps/edr-freight-api/src/modules/reports/operations-classification.ts index f9d65d9b9..3e4bc599d 100644 --- a/apps/edr-freight-api/src/modules/reports/operations-classification.ts +++ b/apps/edr-freight-api/src/modules/reports/operations-classification.ts @@ -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 { +export function allocationLedgerQb( + ctx: ReportContext, + opts: { includeEmptyWagons?: boolean } = {}, +): SelectQueryBuilder { const { params, directions } = ctx; - const qb = ctx.ds - .createQueryBuilder() - .from(WagonBookingAllocation, 'wba') - .innerJoin(TrainSetWagon, 'tsw', 'tsw.id = wba.train_set_wagon_id AND tsw.deleted_at IS NULL') - .innerJoin(TrainSchedule, 'ts', 'ts.train_set_id = tsw.train_set_id AND ts.deleted_at IS NULL') + const qb = ctx.ds.createQueryBuilder(); + + if (opts.includeEmptyWagons) { + qb.from(TrainSetWagon, 'tsw') + .leftJoin( + WagonBookingAllocation, + 'wba', + 'wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL', + ) + .where('tsw.deleted_at IS NULL'); + } else { + qb.from(WagonBookingAllocation, 'wba') + .innerJoin(TrainSetWagon, 'tsw', 'tsw.id = wba.train_set_wagon_id AND tsw.deleted_at IS NULL') + .where('wba.deleted_at IS NULL'); + } + + qb.innerJoin(TrainSchedule, 'ts', 'ts.train_set_id = tsw.train_set_id AND ts.deleted_at IS NULL') .leftJoin(Booking, 'b', 'b.id = wba.booking_id AND b.deleted_at IS NULL') .leftJoin(CargoType, 'ct', 'ct.id = b.cargo_type_id') .leftJoin(Yard, 'oy', 'oy.id = ts.origin_station_id') .leftJoin(Yard, 'dy', 'dy.id = ts.destination_station_id') .leftJoin(OperationsStandard, 'std', STANDARDS_JOIN) - .where('wba.deleted_at IS NULL') .andWhere('ts.status NOT IN (:...deadScheduleStatuses)', { deadScheduleStatuses: DEAD_SCHEDULE_STATUSES, }); @@ -715,10 +758,11 @@ export function applyOperationsFilters( export function applyCategoryFilter( qb: SelectQueryBuilder, params: Record, + categoryExpr: string = CARGO_CATEGORY_EXPR, ): void { const categories = params.categories as string[] | null; if (categories?.length) { - qb.andWhere(`${CARGO_CATEGORY_EXPR} IN (:...categories)`, { categories }); + qb.andWhere(`${categoryExpr} IN (:...categories)`, { categories }); } } diff --git a/apps/edr-freight-api/src/modules/reports/report-runner.service.ts b/apps/edr-freight-api/src/modules/reports/report-runner.service.ts index d38a5ba8a..350fdc4ee 100644 --- a/apps/edr-freight-api/src/modules/reports/report-runner.service.ts +++ b/apps/edr-freight-api/src/modules/reports/report-runner.service.ts @@ -7,7 +7,7 @@ import { normalizePagination, } from '../../common/utils/pagination.util'; import { applyBookingRefDirectionScope } from '../user-trade-access/trade-scope.util'; -import { ReportDefinition, ReportRunResult } from './report.types'; +import { ReportColumn, ReportDefinition, ReportRunResult } from './report.types'; const DAY_MS = 24 * 60 * 60 * 1000; @@ -38,7 +38,7 @@ function coerceParams( const items = csv?.split(',').map((s) => s.trim()).filter(Boolean) ?? []; params[filter.key] = items.length ? items : null; } else { - params[filter.key] = raw[filter.key]?.trim() || null; + params[filter.key] = raw[filter.key]?.trim() || filter.defaultValue || null; } } // idKey, when the report declares one, is a plain string param. @@ -57,19 +57,34 @@ function coerceParams( */ const aliasSortExpr = (key: string): string => `"${key.replace(/"/g, '""')}"`; +/** + * Columns the current filter values don't hide. A hidden column is not in the + * SELECT list of the shape those filters produce, so sorting by one would be a + * 42703 — the sort falls back to the default instead. + */ +export const visibleColumns = ( + def: ReportDefinition, + params: Record, +): ReportColumn[] => + def.columns.filter((c) => + Object.entries(c.hideWhen ?? {}).every(([key, value]) => params[key] !== value), + ); + /** Resolve a client-requested sort column against the report's own whitelist. */ function resolveSort( def: ReportDefinition, + params: Record, sortBy?: string, sortOrder?: string, ): { key: string; expr: string; dir: 'ASC' | 'DESC' } | null { const dir = sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; - const requested = sortBy && def.columns.find((c) => c.key === sortBy && c.sortable); + const columns = visibleColumns(def, params); + const requested = sortBy && columns.find((c) => c.key === sortBy && c.sortable); if (requested) { return { key: requested.key, expr: requested.sortExpr ?? aliasSortExpr(requested.key), dir }; } if (!def.defaultSort) return null; - const fallback = def.columns.find((c) => c.key === def.defaultSort!.key); + const fallback = columns.find((c) => c.key === def.defaultSort!.key); if (!fallback) return null; return { key: fallback.key, @@ -91,7 +106,7 @@ export class ReportRunnerService { const ctx = { ds: this.ds, params, directions }; const qb = def.query(ctx); - const sort = resolveSort(def, raw.sortBy, raw.sortOrder); + const sort = resolveSort(def, params, raw.sortBy, raw.sortOrder); if (sort) qb.orderBy(sort.expr, sort.dir); const { page: pageNum, pageSize, skip, take } = normalizePagination({ @@ -141,7 +156,7 @@ export class ReportRunnerService { const qb = def.query(ctx); // Same sort the on-screen table is using, not always the default — an // export is supposed to match what the user is looking at. - const sort = resolveSort(def, raw.sortBy, raw.sortOrder); + const sort = resolveSort(def, params, raw.sortBy, raw.sortOrder); if (sort) qb.orderBy(sort.expr, sort.dir); const ceiling = limit ?? cap; diff --git a/apps/edr-freight-api/src/modules/reports/report.types.ts b/apps/edr-freight-api/src/modules/reports/report.types.ts index ca7578461..7f1758164 100644 --- a/apps/edr-freight-api/src/modules/reports/report.types.ts +++ b/apps/edr-freight-api/src/modules/reports/report.types.ts @@ -19,6 +19,12 @@ export interface ReportColumn { sortable?: boolean; /** SQL to ORDER BY when this column is sorted, if different from `key`. */ sortExpr?: string; + /** + * Hide the column while a filter holds a given value — how one report serves + * two group-by grains without two column lists. Display only: the value is + * still selected, exported and sortable, it just isn't shown. + */ + hideWhen?: Record; } export type ReportFilterType = 'daterange' | 'date' | 'select' | 'multiselect' | 'text'; @@ -34,6 +40,12 @@ export interface ReportFilterDef { type: ReportFilterType; /** Static option list for select/multiselect. */ options?: ReportFilterOption[]; + /** + * Value the filter takes when the client sends nothing — so a report whose + * shape depends on a filter (see `ReportColumn.hideWhen`) never has to guess + * what "unset" meant. + */ + defaultValue?: string; /** * Resolves the option list from the database instead of declaring it inline — * for filters whose choices are reference data (stations, cargo types). diff --git a/apps/edr-freight-api/src/modules/reports/revenue-classification.ts b/apps/edr-freight-api/src/modules/reports/revenue-classification.ts index e3ba0a65d..550475599 100644 --- a/apps/edr-freight-api/src/modules/reports/revenue-classification.ts +++ b/apps/edr-freight-api/src/modules/reports/revenue-classification.ts @@ -28,8 +28,14 @@ import { ReportContext, ReportFilterDef, ReportFilterOption } from './report.typ // --------------------------------------------------------------------------- export const REVENUE_CATEGORIES: ReportFilterOption[] = [ - { value: 'CONTAINER_IMPORT_MULTIMODAL', label: 'Full Container Import — Multimodal' }, - { value: 'CONTAINER_IMPORT_UNIMODAL', label: 'Full Container Import — Unimodal' }, + { + value: 'CONTAINER_IMPORT_MULTIMODAL', + label: 'Full Container Import — Multimodal', + }, + { + value: 'CONTAINER_IMPORT_UNIMODAL', + label: 'Full Container Import — Unimodal', + }, { value: 'CONTAINER_EXPORT', label: 'Full Container Export' }, { value: 'EMPTY_CONTAINER_REEXPORT', label: 'Empty Container Re-export' }, { value: 'FERTILIZER', label: 'Fertilizer Transportation' }, @@ -332,7 +338,10 @@ export const PERIOD_FILTER: ReportFilterDef = { key: 'period', label: 'Granularity', type: 'select', - options: Object.entries(PERIOD_UNITS).map(([value, u]) => ({ value, label: u.label })), + options: Object.entries(PERIOD_UNITS).map(([value, u]) => ({ + value, + label: u.label, + })), }; /** The timestamp every revenue report buckets and filters on. */ @@ -478,7 +487,12 @@ export const REVENUE_FILTERS: ReportFilterDef[] = [ options: REVENUE_CATEGORIES, }, { key: 'origin', label: 'Origin', type: 'select', optionsQuery: yardOptions }, - { key: 'destination', label: 'Destination', type: 'select', optionsQuery: yardOptions }, + { + key: 'destination', + label: 'Destination', + type: 'select', + optionsQuery: yardOptions, + }, { key: 'customer', label: 'Customer / booking ref', type: 'text' }, { key: 'methods', @@ -537,9 +551,6 @@ export function revenueLedgerQb(ctx: ReportContext): SelectQueryBuilder 'eims_self_test'") - // An umbrella general contract is paid once and drawn down by many orders; - // counting both double-counts its value. - .andWhere("(b.id IS NULL OR b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')") // Mixing ETB and USD into one SUM produces a meaningless number. .andWhere('il.currency = :currency', { currency: currencyOf(params) }); @@ -611,13 +622,13 @@ export function invoiceLedgerQb(ctx: ReportContext): SelectQueryBuilder 'eims_self_test'") - .andWhere("(b.id IS NULL OR b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')") .andWhere('i.currency = :currency', { currency: currencyOf(params) }); if (params.dateFrom) qb.andWhere(`${REVENUE_DATE} >= :dateFrom`, { dateFrom: params.dateFrom }); if (params.dateTo) qb.andWhere(`${REVENUE_DATE} < :dateTo`, { dateTo: params.dateTo }); if (params.origin) qb.andWhere('oy.code = :origin', { origin: params.origin }); - if (params.destination) qb.andWhere('dy.code = :destination', { destination: params.destination }); + if (params.destination) + qb.andWhere('dy.code = :destination', { destination: params.destination }); if (params.customer) { qb.andWhere( '(co.name ILIKE :customer OR slc.name ILIKE :customer OR b.reference ILIKE :customer)', @@ -630,14 +641,37 @@ export function invoiceLedgerQb(ctx: ReportContext): SelectQueryBuilder | null; + /** OPEN = accepting/holding bookings; FULL = train filled; CLOSED = manually closed. Orthogonal to `status`. */ @Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' }) bookingWindowStatus!: string; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 5c76b7803..9051bb816 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -241,6 +241,45 @@ describe('BookingBatchService — PAID reconcile', () => { ).not.toHaveBeenCalled(); }); + it('ensurePaidBookingAllocated never re-places a MANUAL_ONLY booking (removed from a train by staff)', async () => { + dataSource.getRepository().findOne.mockResolvedValue({ + ...paidBooking, + trainScheduleId: null, + schedulingStatus: 'MANUAL_ONLY', + } as unknown as Booking); + + await service.ensurePaidBookingAllocated(bookingId); + + expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled(); + expect(trainSchedulingService.tryAutoWagonAllocation).not.toHaveBeenCalled(); + expect(dataSource.getRepository().update).not.toHaveBeenCalled(); + }); + + it('ensurePaidBookingAllocated skips a MANUAL_ONLY booking even when still pinned to a schedule', async () => { + dataSource.getRepository().findOne.mockResolvedValue({ + ...paidBooking, + schedulingStatus: 'MANUAL_ONLY', + } as unknown as Booking); + + await service.ensurePaidBookingAllocated(bookingId); + + expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled(); + expect(trainSchedulingService.tryAutoWagonAllocation).not.toHaveBeenCalled(); + }); + + it('reconcilePaidUnlinked leaves MANUAL_ONLY bookings alone', async () => { + bookingsRepository.findPaidUnlinkedForSchedule.mockResolvedValue([ + { ...paidBooking, schedulingStatus: 'MANUAL_ONLY' }, + ]); + + await service.reconcilePaidUnlinked(scheduleId); + + expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled(); + expect( + trainSchedulingService.previewPaidBookingWagonShortage, + ).not.toHaveBeenCalled(); + }); + it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => { const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(0); const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 625ccfda7..d787f737d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -76,7 +76,7 @@ import { bookingCargoTons, bulkItemsFitFor, bulkItemWagonsRequired, - bulkTonsPerWagon, + bulkTonsPerWagonFor, bookingGrossWeightTons, deriveTrainCapacityFromLocomotive, sizePartialOfferWagons, @@ -577,6 +577,11 @@ export class BookingBatchService implements OnModuleInit { // train 30s after being cancelled. Never resurrect a dead booking. if (["CANCELLED", "EXPIRED", "REJECTED", "COMPLETED"].includes(booking.status)) return; + // Staff removed this booking from a train (dispatch left-behind / manual + // unassign) — every auto-allocation rescue below must leave it alone, or + // the next document review / sweep silently retakes the space it was + // pulled from. Only a manual staff assignment may re-place it. + if (booking.schedulingStatus === "MANUAL_ONLY") return; if (!booking.trainScheduleId) { // A paid booking with no train is money taken and nothing boarding. The // hold was expired before the payment landed (webhook lag beat the @@ -1553,6 +1558,8 @@ export class BookingBatchService implements OnModuleInit { for (const booking of unlinked) { // Held on purpose (paid, no wagon free) — the cron must not undo it. if (booking.schedulingStatus === "WAITING_FOR_WAGON") continue; + // Removed from a train by staff — manual re-assignment only. + if (booking.schedulingStatus === "MANUAL_ONLY") continue; if (await this.holdIfWagonShort(scheduleId, booking)) continue; await this.allocate(scheduleId, booking, "paid"); this.logger.log( @@ -2884,7 +2891,8 @@ export class BookingBatchService implements OnModuleInit { .map((o) => ({ ...o, free: c.stock?.availableFor([o.wagonTypeId], leg) ?? 0, - takePerWagon: bulkTonsPerWagon( + takePerWagon: bulkTonsPerWagonFor( + booking, booking.cargoType, o.wagonTypeId, o.dims.capacityTons, @@ -4722,7 +4730,8 @@ export class BookingBatchService implements OnModuleInit { const cargoTons = bookingCargoTons(booking); // PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T // wagon), so divide by the cap where one is configured for this type. - const tonsPerWagon = bulkTonsPerWagon( + const tonsPerWagon = bulkTonsPerWagonFor( + booking, booking.cargoType, booking.cargoType?.wagonTypes?.[0]?.id, capacityTons, @@ -4798,7 +4807,8 @@ export class BookingBatchService implements OnModuleInit { const wagonTypeId = o.wagonTypeId as string; // Each type sized on its OWN per-wagon tonnage cap, not just its rating // — a type capped lower swallows less per wagon. - const tonsPerWagon = bulkTonsPerWagon( + const tonsPerWagon = bulkTonsPerWagonFor( + booking, booking.cargoType, wagonTypeId, o.dims.capacityTons, @@ -5210,7 +5220,8 @@ export class BookingBatchService implements OnModuleInit { .map((o) => ({ ...o, free: stock.availableFor([o.wagonTypeId], leg), - takePerWagon: bulkTonsPerWagon( + takePerWagon: bulkTonsPerWagonFor( + booking, booking.cargoType, o.wagonTypeId, o.dims.capacityTons, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index 56b28fb7f..c7256d82d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -18,7 +18,11 @@ import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon- import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { Yard } from '../rule-engine/entities/yard.entity'; import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; -import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { + StationWorkLog, + StationWorkPhaseLog, + TrainSchedule, +} from '../train-schedules/entities/train-schedule.entity'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; import { Wagon } from '../wagons/entities/wagon.entity'; @@ -77,6 +81,7 @@ export class BookingJourneyService { ); } await this.assertTrainAtYard(schedule, booking.originYardId, 'origin'); + this.assertStationWorkStarted(schedule, booking.originYardId, 'loading'); await this.assertYardCanHandleCargo(booking, booking.originYardId, 'origin'); // Export cargo must be in the warehouse with a GRN before it can be loaded, // however it arrived and whatever it is allocated to. @@ -166,6 +171,7 @@ export class BookingJourneyService { ); } await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination'); + this.assertStationWorkStarted(schedule, booking.destinationYardId, 'unloading'); await this.assertYardCanHandleCargo(booking, booking.destinationYardId, 'destination'); // Intercity has no clearance/delivery tail — unloading completes it. Import/ @@ -294,6 +300,9 @@ export class BookingJourneyService { // dispatch — assertTrainAtYard allows origin loading in that state, so // the UI position must agree or origin Load buttons grey out wrongly. trainAtYardId: latest?.yardId ?? schedule.originStationId, + // Per-yard loading/unloading time windows — the UI derives its + // start/end buttons and the load/unload gating from these. + stationWorkLogs: schedule.stationWorkLogs ?? {}, yards: [...byYard.values()], }; } @@ -414,6 +423,62 @@ export class BookingJourneyService { return rows.map((r) => r.id); } + /** + * Record a station's loading/unloading time-window click (or edit it — an + * explicit `at` on an already-set edge overwrites the timestamp under the + * same permission that set it). Rules: end needs start, start ≤ end, no + * future times. Stored as ISO strings in train_schedules.station_work_logs. + * ponytail: read-modify-write on the jsonb — two operators clicking the same + * schedule in the same instant can clobber one edge; move to jsonb_set if + * that ever bites. + */ + async recordStationWork( + scheduleId: string, + yardId: string, + phase: 'loading' | 'unloading', + edge: 'start' | 'end', + at?: string, + userId?: string | null, + ) { + const schedule = await this.getSchedule(scheduleId); + const when = at ? new Date(at) : new Date(); + if (Number.isNaN(when.getTime())) { + throw new BadRequestException('Invalid timestamp'); + } + if (when.getTime() > Date.now() + 60_000) { + throw new BadRequestException(`${phase} ${edge} time cannot be in the future`); + } + + const logs: Record = schedule.stationWorkLogs ?? {}; + const entry: StationWorkLog = logs[yardId] ?? {}; + const ph: StationWorkPhaseLog = entry[phase] ?? {}; + + if (edge === 'end') { + if (!ph.startedAt) { + throw new BadRequestException(`Start ${phase} at this station first`); + } + if (when.getTime() < new Date(ph.startedAt).getTime()) { + throw new BadRequestException(`${phase} end cannot be before its start`); + } + ph.endedAt = when.toISOString(); + ph.endedByUserId = userId ?? null; + } else { + if (ph.endedAt && when.getTime() > new Date(ph.endedAt).getTime()) { + throw new BadRequestException(`${phase} start cannot be after its end`); + } + ph.startedAt = when.toISOString(); + ph.startedByUserId = userId ?? null; + } + + entry[phase] = ph; + logs[yardId] = entry; + await this.dataSource + .getRepository(TrainSchedule) + .update(scheduleId, { stationWorkLogs: logs }); + + return { scheduleId, yardId, phase, ...ph }; + } + // ---- helpers --------------------------------------------------------------- private async getSchedule(scheduleId: string): Promise { @@ -481,6 +546,27 @@ export class BookingJourneyService { } } + /** + * Loading/unloading a booking is only allowed inside a started work window + * at that yard — the operator must click "Start loading"/"Start unloading" + * (recordStationWork) before touching cargo. The window's END is not checked: + * a straggler booking can still be confirmed after the end click, and the + * operator can push the end time later (it's editable) if that matters. + * Lives here (not the controller) so the checkpoint-driven autoUnloadAtYard + * path is gated too — the user wants unloading fully manual. + */ + private assertStationWorkStarted( + schedule: TrainSchedule, + yardId: string, + phase: 'loading' | 'unloading', + ): void { + if (!schedule.stationWorkLogs?.[yardId]?.[phase]?.startedAt) { + throw new BadRequestException( + `Start ${phase} at this station first — the ${phase} time window has not been started`, + ); + } + } + /** * The train is "at" a yard when the latest recorded checkpoint is that yard, * or — for a booking boarding at the train's own origin — when the train has diff --git a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index e9a747784..efa08a927 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -18,14 +18,19 @@ import { TrainSchedulingCreate, TrainSchedulingEditTrainNumber, TrainSchedulingLoad, + TrainSchedulingLoadingEnd, + TrainSchedulingLoadingStart, TrainSchedulingReschedule, TrainSchedulingUnload, + TrainSchedulingUnloadingEnd, + TrainSchedulingUnloadingStart, TrainSchedulingRulesManage, TrainSchedulingUpdate, TrainSchedulingView, } from "../../../common/booking-guards"; import { FREIGHT_PERMS } from "../../../seed/freight-permissions.registry"; import { AcceptIntercityBookingsDto } from "../dto/accept-intercity-bookings.dto"; +import { StationWorkDto } from "../dto/station-work.dto"; import { AssignBookingsDto } from "../dto/assign-bookings.dto"; import { AssignUnassignedBookingDto } from "../dto/assign-unassigned-booking.dto"; import { SwitchGovernmentBookingDto } from "../dto/switch-government-booking.dto"; @@ -566,8 +571,9 @@ export class TrainSchedulingController { dispatchSchedule( @Param("id", ParseUUIDPipe) id: string, @Body() dto: DispatchScheduleDto, + @CurrentUser() user: AuthUserPayload, ) { - return this.trainSchedulingService.dispatchSchedule(id, dto); + return this.trainSchedulingService.dispatchSchedule(id, dto, resolveAuthUserId(user)); } @Get("intercity/bookings") @@ -613,6 +619,68 @@ export class TrainSchedulingController { return this.bookingJourneyService.listYardWork(id); } + @Post("schedules/:id/stations/:yardId/loading/start") + @TrainSchedulingLoadingStart() + @ApiOperation({ + summary: + "Start (or correct, via `at`) this station's loading time window — required before bookings can be loaded there", + }) + startStationLoading( + @Param("id", ParseUUIDPipe) id: string, + @Param("yardId", ParseUUIDPipe) yardId: string, + @Body() dto: StationWorkDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.bookingJourneyService.recordStationWork( + id, yardId, "loading", "start", dto.at, resolveAuthUserId(user), + ); + } + + @Post("schedules/:id/stations/:yardId/loading/end") + @TrainSchedulingLoadingEnd() + @ApiOperation({ summary: "End (or correct, via `at`) this station's loading time window" }) + endStationLoading( + @Param("id", ParseUUIDPipe) id: string, + @Param("yardId", ParseUUIDPipe) yardId: string, + @Body() dto: StationWorkDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.bookingJourneyService.recordStationWork( + id, yardId, "loading", "end", dto.at, resolveAuthUserId(user), + ); + } + + @Post("schedules/:id/stations/:yardId/unloading/start") + @TrainSchedulingUnloadingStart() + @ApiOperation({ + summary: + "Start (or correct, via `at`) this station's unloading time window — required before bookings can be unloaded there", + }) + startStationUnloading( + @Param("id", ParseUUIDPipe) id: string, + @Param("yardId", ParseUUIDPipe) yardId: string, + @Body() dto: StationWorkDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.bookingJourneyService.recordStationWork( + id, yardId, "unloading", "start", dto.at, resolveAuthUserId(user), + ); + } + + @Post("schedules/:id/stations/:yardId/unloading/end") + @TrainSchedulingUnloadingEnd() + @ApiOperation({ summary: "End (or correct, via `at`) this station's unloading time window" }) + endStationUnloading( + @Param("id", ParseUUIDPipe) id: string, + @Param("yardId", ParseUUIDPipe) yardId: string, + @Body() dto: StationWorkDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.bookingJourneyService.recordStationWork( + id, yardId, "unloading", "end", dto.at, resolveAuthUserId(user), + ); + } + @Post("schedules/:id/bookings/:bookingId/load") @TrainSchedulingLoad() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts index 5f9a7a1bc..06c363bc4 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts @@ -1,11 +1,13 @@ import { ApiProperty } from '@nestjs/swagger'; import { TrainCheckpointKind } from '@edr/types'; import { + IsArray, IsEnum, IsInt, IsISO8601, IsOptional, IsString, + IsUUID, MaxLength, Min, } from 'class-validator'; @@ -113,4 +115,20 @@ export class DispatchScheduleDto { @IsOptional() @IsISO8601() actualDepartureAt?: string; + + /** + * Loading is a manual staff decision. When present, only these bookings are + * auto-loaded at the origin; every other unloaded origin boarder is left + * behind — deallocated from its wagon and returned to the booking pool. + * Absent (older clients) = load every origin boarder, the historic behavior. + */ + @ApiProperty({ + required: false, + description: + 'Origin-yard bookings confirmed loaded; the rest are unassigned back to the pool. Omit to auto-load all.', + }) + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + loadedBookingIds?: string[]; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/station-work.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/station-work.dto.ts new file mode 100644 index 000000000..6100b037a --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/station-work.dto.ts @@ -0,0 +1,14 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsISO8601, IsOptional } from 'class-validator'; + +/** + * A station loading/unloading window click. `at` omitted = "now" (the button + * click); `at` given = record or correct the timestamp after the fact — same + * endpoint, same permission. + */ +export class StationWorkDto { + @ApiPropertyOptional({ description: 'ISO timestamp; omitted = now. Never in the future.' }) + @IsOptional() + @IsISO8601() + at?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts index 15cf4f592..2b8971e81 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts @@ -170,7 +170,7 @@ describe('TrainSchedulingService', () => { wagonAllocationContainerItemsRepository as never, wagonAllocationBulkLoadsRepository as never, trainCheckpointEventsRepository as never, - {} as never, // trainCompositionRemovalLogRepository + { create: jest.fn() } as never, // trainCompositionRemovalLogRepository { autoUnloadArrivedBookings: jest.fn(), autoUnloadExportAtDjibouti: jest.fn(), @@ -182,7 +182,7 @@ describe('TrainSchedulingService', () => { { autoArriveAtFinalYard: jest.fn().mockResolvedValue([]), } as never, // bookingJourneyService - { dispatched: jest.fn(), arrived: jest.fn() } as never, // bookingNotifier + { dispatched: jest.fn(), arrived: jest.fn(), removedFromTrain: jest.fn() } as never, // bookingNotifier { getLogoImageUrl: jest.fn().mockResolvedValue(null) } as never, // logoSettings ); @@ -1134,6 +1134,107 @@ describe('TrainSchedulingService', () => { expect(html).toContain('2 (1 empty)'); }); + it('marks a leg slot on the import document as TO BE LOADED and keeps it out of the loaded tallies', () => { + const loadList = { + generatedAt: '2026-07-17T08:00:00.000Z', + trainScheduleId: 'schedule-1', + trainNumber: '7002', + route: 'DCT/SGTD → GMP', + origin: 'DCT/SGTD', + destination: 'GMP', + totalBookings: 2, + wagons: [ + { + sequenceNo: 1, + wagonNumber: 'W-ICY', + boardYard: 'Dire Dawa Port', + alightYard: null, + allocations: [ + { + ...loadedAllocation, + containerItems: [{ containerNumber: 'ICY-001' }], + }, + ], + }, + { + sequenceNo: 2, + wagonNumber: 'W-IMP', + boardYard: null, + alightYard: null, + allocations: [ + { + ...loadedAllocation, + containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }], + }, + ], + }, + ], + operation: { status: {} }, + }; + + const html = (service as never as { + buildImportLoadListHtml: (l: unknown) => string; + }).buildImportLoadListHtml(loadList); + + expect(html).toContain('TO BE LOADED AT DIRE DAWA PORT'); + // Departure station of the leg slot is its board yard, not the origin. + expect(html).toContain('Dire Dawa Port'); + // Only the origin-loaded container counts; the leg slot's tallies separately. + expect(html).toContain('Total containers1'); + expect(html).toContain('To load en route1 containers'); + }); + + it('marks a leg slot on the export document as TO LOAD AT its board yard and keeps it out of the tallies', () => { + const sizedAllocation = { + ...loadedAllocation, + containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }], + }; + const legWagon = { ...makeWagon(2, 'W-LEG', [sizedAllocation]), id: 'slot-leg' }; + const schedule = { + id: 'schedule-1', + trainNumber: '8302', + direction: 'EXPORT', + trainSet: { wagons: [{ ...makeWagon(1, 'W-001', [sizedAllocation]), id: 'slot-1' }, legWagon] }, + scheduleBookings: [], + }; + + const html = (service as never as { + buildExportLoadListHtml: (s: unknown, o?: unknown) => string; + }).buildExportLoadListHtml(schedule, { + pendingBoardYardLabelBySlot: new Map([['slot-leg', 'Dire Dawa Port']]), + }); + + expect(html).toContain('TO LOAD AT DIRE DAWA PORT'); + expect(html).toContain('Total containers1'); + expect(html).toContain('To load en route1 containers'); + }); + + it('prints coupled/switched wagons logged at this stop, and omits the box when there are none', () => { + const schedule = { + id: 'schedule-1', + trainNumber: '8302', + direction: 'EXPORT', + trainSet: { wagons: [{ ...makeWagon(1, 'W-001', [loadedAllocation]), id: 'slot-1' }] }, + scheduleBookings: [], + }; + const build = (service as never as { + buildExportLoadListHtml: (s: unknown, o?: unknown) => string; + }).buildExportLoadListHtml.bind(service); + + const withChanges = build(schedule, { + consistChangesAtStop: [ + { action: 'ADD', wagonNumber: 'W-1002' }, + { action: 'SWITCH', wagonNumber: 'W-0501 → W-1003' }, + ], + }); + expect(withChanges).toContain('Consist changed at this stop'); + expect(withChanges).toContain('Coupled: W-1002'); + expect(withChanges).toContain('Uncoupled — replaced: W-0501 → W-1003'); + + const withoutChanges = build(schedule, {}); + expect(withoutChanges).not.toContain('Consist changed at this stop'); + }); + it('lists loaded empty containers by number and states they are empty', () => { const schedule = { id: 'schedule-1', @@ -1264,6 +1365,25 @@ describe('TrainSchedulingService', () => { expect(html).toContain('2 (1 empty)'); }); + it('keeps whole-route cargo whose allocation never left PLANNED (import flow) on board', () => { + // The import flow confirms loading at schedule level and never flips the + // allocation to LOADED — the cargo is still on the train until DEPARTED. + const schedule = { + trainSet: { + wagons: [ + { ...makeWagon(1, 'W-IMP', [allocWith({ status: 'PLANNED' })]), status: 'RESERVED' }, + { ...makeWagon(2, 'W-ICY', [allocWith({ status: 'LOADED', bookingId: 'booking-2' })]), status: 'RESERVED', boardYardId: 'yard-mid' }, + ], + }, + scheduleBookings: [], + }; + + const { wagons } = onBoardView(schedule); + const byNumber = wagons as Array<{ physicalWagon: { wagonNumber: string }; allocations: unknown[] }>; + expect(byNumber.map((w) => w.physicalWagon.wagonNumber)).toEqual(['W-IMP', 'W-ICY']); + expect(byNumber[0].allocations).toHaveLength(1); + }); + it('hides a leg slot (boardYardId set) until it has confirmed LOADED cargo', () => { const legWagonEmpty = { ...makeWagon(2, 'W-LEG', [allocWith({ status: 'RESERVED' })]), status: 'RESERVED', boardYardId: 'yard-mid' }; const legWagonLoaded = { ...makeWagon(3, 'W-LEG2', [allocWith({ status: 'LOADED' })]), status: 'RESERVED', boardYardId: 'yard-mid' }; @@ -1640,6 +1760,85 @@ describe('TrainSchedulingService', () => { }); }); + describe('unassignBooking — MANUAL_ONLY status', () => { + const scheduleId = 'sched-rm-1'; + const removed = makeBooking('bk-rm', 'BKG-RM', 100, 5, '20FT', 5, undefined, undefined, undefined, { + status: 'PAID', + wagonsRequired: 5, + }); + + const graph = { + id: scheduleId, + status: 'DRAFT', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + trainSetId: 'ts-rm', + trainSet: { + id: 'ts-rm', + locomotive, + trainId: null, + wagons: [{ id: 'tsw-rm-1', allocations: [{ id: 'alloc-rm-1', bookingId: 'bk-rm' }] }], + }, + scheduleBookings: [{ bookingId: 'bk-rm' }], + }; + + const txManager = { + getRepository: jest.fn(() => ({ + find: jest.fn().mockResolvedValue([]), + findOne: jest.fn().mockResolvedValue(null), + update: jest.fn().mockResolvedValue(undefined), + delete: jest.fn().mockResolvedValue(undefined), + save: jest.fn().mockResolvedValue(undefined), + create: jest.fn((x: unknown) => x), + })), + }; + + beforeEach(() => { + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(graph); + bookingsRepository.findById = jest.fn().mockResolvedValue(removed); + bookingsRepository.updateSchedulingFields.mockResolvedValue(undefined); + dataSource.transaction.mockImplementation( + async (fn: (m: unknown) => Promise) => fn(txManager), + ); + jest + .spyOn( + service as never as { getTrainScheduleById: (id: string) => Promise }, + 'getTrainScheduleById' as never, + ) + .mockResolvedValue({ id: scheduleId } as never); + }); + + it('marks a staff-removed paid booking MANUAL_ONLY and fully detaches it', async () => { + await service.unassignBooking(scheduleId, 'bk-rm', 'user-1'); + + expect(bookingsRepository.updateSchedulingFields).toHaveBeenCalledWith( + 'bk-rm', + expect.objectContaining({ + schedulingStatus: 'MANUAL_ONLY', + trainScheduleId: null, + wagonsRequired: null, + }), + expect.anything(), + ); + expect(trainScheduleBookingsRepository.deleteByScheduleAndBooking).toHaveBeenCalledWith( + scheduleId, + 'bk-rm', + expect.anything(), + ); + expect(wagonAllocationContainerItemsRepository.deleteByAllocationIds).toHaveBeenCalledWith( + ['alloc-rm-1'], + expect.anything(), + ); + }); + + it('never marks ELIGIBLE — a removed booking must not rejoin the auto pool', async () => { + await service.unassignBooking(scheduleId, 'bk-rm', 'user-1'); + + const updates = bookingsRepository.updateSchedulingFields.mock.calls.map((c) => c[1]); + expect(updates.some((u) => u.schedulingStatus === 'ELIGIBLE')).toBe(false); + }); + }); + describe('updateCheckpoint — leg time correction', () => { const t = (h: number) => new Date(Date.UTC(2026, 0, 1, h)); const schedule = { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index c4ca3da59..71153c547 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -55,7 +55,10 @@ import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity' import { TrainSet } from '../../train-sets/entities/train-set.entity'; import { ScheduleWagonAdjustmentLog } from '../../train-schedules/entities/schedule-wagon-adjustment-log.entity'; import { TrainScheduleBooking } from '../../train-schedules/entities/train-schedule-booking.entity'; -import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; +import { + TrainSchedule, + type StationWorkPhaseLog, +} from '../../train-schedules/entities/train-schedule.entity'; import { WagonAllocationContainerItem } from '../../train-schedules/entities/wagon-allocation-container-item.entity'; import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity'; import { TrainScheduleBookingsRepository } from '../../train-schedules/train-schedule-bookings.repository'; @@ -156,7 +159,7 @@ import { bookingCargoTons, bulkItemsFitFor, bulkItemWagonsRequired, - bulkTonsPerWagon, + bulkTonsPerWagonFor, consistViolations, deriveTrainCapacityFromLocomotive, combinedLocomotiveLimits, @@ -2370,16 +2373,22 @@ export class TrainSchedulingService { if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); } - if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { - throw new BadRequestException('Cannot unassign from a finalized or dispatched schedule'); - } - const link = schedule.scheduleBookings?.find((sb) => sb.bookingId === bookingId); if (!link) { throw new NotFoundException(`Booking ${bookingId} is not assigned to this schedule`); } const booking = await this.bookingsRepository.findById(bookingId); + // A dispatched train may still shed a booking staff left behind at its + // boarding yard (dispatch dialog / log-pass "leave") — but never one whose + // cargo is actually on the train. + const leftBehindWhileDispatched = + schedule.status === 'DISPATCHED' && + !booking?.loadedAt && + booking?.status !== 'IN_TRANSIT'; + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status) && !leftBehindWhileDispatched) { + throw new BadRequestException('Cannot unassign from a finalized or dispatched schedule'); + } if (booking?.isGovernment) { throw new BadRequestException( 'Government bookings cannot be removed from a train. They can only be switched onto another allocation.', @@ -2409,7 +2418,12 @@ export class TrainSchedulingService { ); const booking = await this.bookingsRepository.findById(bookingId); - const schedulingStatus = this.resolvePostUnassignStatus(booking); + // Removed from a train by staff → MANUAL_ONLY: the paid booking must not + // be auto re-placed by any allocation sweep (it would retake the space it + // was just pulled from). Staff re-assign it manually; assign resets the + // status to SCHEDULED. Schedule *cancellation* keeps the old behaviour + // (resolvePostUnassignStatus) — there the train died, not the booking. + const schedulingStatus = SchedulingStatus.ManualOnly; // Clear the schedule pointer too: unassign fully detaches the booking from // this train. Leaving trainScheduleId set glued the booking to a schedule // that may then be dispatched/cancelled/deleted, orphaning it — the @@ -2447,6 +2461,21 @@ export class TrainSchedulingService { for (const slot of survivingSlots) { const slotAllocations = slot.allocations ?? []; if (slotAllocations.length === 0) { + // A dispatched train pinned its wagons (ASSIGNED + schedule id) at + // departure — freeing the slot must also free the physical wagon, or + // the checkpoint position-fix keeps dragging it along the corridor. + if (schedule.status === 'DISPATCHED' && slot.physicalWagonId) { + const wagon = await manager + .getRepository(Wagon) + .findOne({ where: { id: slot.physicalWagonId } }); + if (wagon && wagon.currentTrainScheduleId === scheduleId) { + await manager.getRepository(Wagon).update(wagon.id, { + currentTrainScheduleId: null, + trainSetWagonId: null, + status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, + }); + } + } await manager.getRepository(TrainSetWagon).delete(slot.id); continue; } @@ -2476,8 +2505,10 @@ export class TrainSchedulingService { // Freed wagons may un-full the train — re-derive the window status (this // also revives a DONE window pre-departure so the freed space is bookable - // again for import/export). - await this.bookingBatchService?.refreshWindowStatus(scheduleId); + // again for import/export). A dispatched train's window stays CLOSED. + if (schedule.status !== 'DISPATCHED') { + await this.bookingBatchService?.refreshWindowStatus(scheduleId); + } await this.trainCompositionRemovalLogRepository.create({ scheduleId, @@ -2842,14 +2873,50 @@ export class TrainSchedulingService { return this.getTrainScheduleById(scheduleId); } - async dispatchSchedule(scheduleId: string, dto: DispatchScheduleDto = {}) { - const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + async dispatchSchedule(scheduleId: string, dto: DispatchScheduleDto = {}, userId?: string) { + let schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); } if (schedule.status !== TrainScheduleStatusEnum.Scheduled) { throw new BadRequestException('Only SCHEDULED trains can be dispatched'); } + // Loading is a manual staff decision: when the dispatch dialog sends the + // checked list, every other unloaded origin boarder is left behind — + // deallocated from its wagon and returned to the booking pool — so the + // origin auto-load below only ever touches confirmed cargo. Government + // bookings cannot be unassigned and keep the historic auto-load. + if (dto.loadedBookingIds) { + const keep = new Set(dto.loadedBookingIds); + const candidates = await this.unloadedOriginBoarderIds(scheduleId, schedule.originStationId); + const leftBehind = candidates.filter((id) => !keep.has(id)); + for (const bookingId of leftBehind) { + await this.unassignBooking(scheduleId, bookingId, userId); + } + if (leftBehind.length) { + // Unassign deleted allocations and slots — reload the graph dispatch works on. + const reloaded = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!reloaded) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + schedule = reloaded; + } + } + // Dispatch requires the origin's loading window to be COMPLETE: started + // and ended. Not started or still open both block — a train departs only + // after loading was formally opened and closed. + const originLoadingLog = + schedule.stationWorkLogs?.[schedule.originStationId]?.loading; + if (!originLoadingLog?.startedAt) { + throw new BadRequestException( + 'Start (and end) the loading window at the origin station before dispatching', + ); + } + if (!originLoadingLog?.endedAt) { + throw new BadRequestException( + 'End the loading window at the origin station before dispatching', + ); + } // Staff may record the departure after the fact — past is fine, future is not. const now = dto.actualDepartureAt ? new Date(dto.actualDepartureAt) : new Date(); this.assertNotFuture(now, 'Departure time'); @@ -2895,7 +2962,7 @@ export class TrainSchedulingService { actualDepartureAt: now, trainNumber, // Freeze the wagon plan the moment the train leaves the editable phase. - wagonAllocationSnapshot: this.buildWagonAllocationSnapshot( + wagonAllocationSnapshot: await this.buildWagonAllocationSnapshot( schedule, TrainScheduleStatusEnum.Dispatched, now, @@ -3071,6 +3138,34 @@ export class TrainSchedulingService { }); } + /** + * Origin boarders the dispatch dialog decides over: unloaded (no journey + * load, no workspace LOADED flag), boardable, non-government. Boardable is + * PAID — or FULLY_EXECUTED for shipping-line bookings, which never prepay + * (their charge sits on the credit ledger) yet ride from accept. + */ + private async unloadedOriginBoarderIds( + scheduleId: string, + originYardId: string, + ): Promise { + const rows: Array<{ id: string }> = await this.dataSource.query( + `SELECT b.id + FROM freight.bookings b + JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id + WHERE tsb.train_schedule_id = $1 + AND tsb.deleted_at IS NULL + AND b.deleted_at IS NULL + AND b.origin_yard_id = $2 + AND b.loaded_at IS NULL + AND COALESCE(tsb.loading_status, 'UNLOADED') <> 'LOADED' + AND b.is_government = false + AND (b.status = 'PAID' + OR (b.shipping_line_company_id IS NOT NULL AND b.status = 'FULLY_EXECUTED'))`, + [scheduleId, originYardId], + ); + return rows.map((r) => r.id); + } + async getImportDjiboutiOperation(scheduleId: string) { const schedule = await this.getDjiboutiGatepassSchedule(scheduleId); const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); @@ -3302,6 +3397,11 @@ export class TrainSchedulingService { const schedule = await this.getImportDjiboutiSchedule(scheduleId); const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); const generatedAt = operation.loadListGeneratedAt ?? new Date(); + // Leg slots (boardYardId set) couple to the train mid-corridor — this + // Djibouti-side document must say where, not list their cargo as loaded here. + const slotYardLabels = await this.yardLabelsById( + (schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]), + ); await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, { loadListGeneratedAt: generatedAt, @@ -3328,6 +3428,8 @@ export class TrainSchedulingService { wagonType: wagon.wagonType?.code ?? wagon.wagonType?.name ?? null, tareWeightTons: wagon.wagonType?.tareWeightTons ?? null, equatedLengthM: wagon.wagonType?.equatedLengthM ?? null, + boardYard: wagon.boardYardId ? (slotYardLabels.get(wagon.boardYardId) ?? 'en route') : null, + alightYard: wagon.alightYardId ? (slotYardLabels.get(wagon.alightYardId) ?? 'en route') : null, allocations: (wagon.allocations ?? []).map((allocation) => ({ bookingId: allocation.bookingId, bookingReference: allocation.booking?.reference ?? null, @@ -3368,7 +3470,19 @@ export class TrainSchedulingService { throw new BadRequestException('Export marshalling document applies only to EXPORT schedules'); } + // Leg slots couple mid-corridor — this origin document must say where their + // cargo boards instead of listing it as loaded here (see the import list). + const slotYardLabels = await this.yardLabelsById( + (schedule.trainSet?.wagons ?? []).map((wagon) => wagon.boardYardId), + ); + const pendingBoardYardLabelBySlot = new Map( + (schedule.trainSet?.wagons ?? []) + .filter((wagon) => wagon.boardYardId) + .map((wagon) => [wagon.id, slotYardLabels.get(wagon.boardYardId!) ?? 'en route']), + ); + const html = this.buildExportLoadListHtml(schedule, { + pendingBoardYardLabelBySlot, emptyContainers: await this.loadedEmptyContainers(scheduleId), logoImageUrl: await this.logoSettings.getLogoImageUrl(), }); @@ -3386,8 +3500,11 @@ export class TrainSchedulingService { * intercity marshalling ("Marshalling 2") document printed after mid-corridor * station work. A wagon slot is on the train iff it has not DEPARTED and * either rides the whole corridor (no boardYardId) or has confirmed LOADED - * cargo. Kept wagons carry only their LOADED allocations (DEPARTED = - * unloaded, PLANNED/RESERVED = not on board yet). + * cargo. Whole-route cargo counts as on board unless DEPARTED (unloaded) — + * the import flow confirms loading at schedule level and never flips the + * allocation to LOADED, so requiring LOADED here rendered every import wagon + * as EMPTY. Leg slots (boardYardId set, coupled mid-corridor) still require + * confirmed LOADED cargo before they appear. * ponytail: boardYardId presence is the "boarded yet?" heuristic; upgrade * path is comparing the board yard against the latest checkpoint sequence. */ @@ -3403,7 +3520,9 @@ export class TrainSchedulingService { }) .map((wagon) => ({ ...wagon, - allocations: (wagon.allocations ?? []).filter((a) => a.status === 'LOADED'), + allocations: (wagon.allocations ?? []).filter((a) => + wagon.boardYardId == null ? a.status !== 'DEPARTED' : a.status === 'LOADED', + ), })) as TrainSetWagon[]; const onBoardBookingIds = new Set( @@ -3438,6 +3557,16 @@ export class TrainSchedulingService { : `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? 'origin'} — no checkpoint recorded`; const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule); + // Couples/switches logged AT THIS STOP — what staff standing here actually + // just did to the consist. Bare trims (REMOVE, no replacement) are left + // out: nothing new to point staff at for those. Origin adjustments (a + // different yard) don't show up on this stop's document. + const consistChangesAtStop = last + ? await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({ + where: { trainScheduleId: scheduleId, yardId: last.yardId, action: In(['ADD', 'SWITCH']) }, + order: { occurredAt: 'DESC' }, + }) + : []; const html = this.buildExportLoadListHtml(schedule, { title: 'Intercity Marshalling Document / Load List (Marshalling 2)', positionLabel, @@ -3445,6 +3574,7 @@ export class TrainSchedulingService { unassignedBookings, emptyContainers: await this.loadedEmptyContainers(scheduleId), logoImageUrl: await this.logoSettings.getLogoImageUrl(), + consistChangesAtStop, }); // Styled table-aware fallback (marshalling grid) — see importLoadListDocument. const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list'); @@ -3493,6 +3623,15 @@ export class TrainSchedulingService { .find({ where: { trainScheduleId: scheduleId } }); } + private async yardLabelsById( + ids: Array, + ): Promise> { + const unique = [...new Set(ids.filter((id): id is string => Boolean(id)))]; + if (!unique.length) return new Map(); + const yards = await this.dataSource.getRepository(Yard).find({ where: { id: In(unique) } }); + return new Map(yards.map((yard) => [yard.id, yard.label || yard.code])); + } + private buildExportLoadListHtml( schedule: TrainSchedule, opts?: { @@ -3502,6 +3641,13 @@ export class TrainSchedulingService { unassignedBookings?: Booking[]; emptyContainers?: EmptyContainerReturn[]; logoImageUrl?: string | null; + // Slots that couple to the train downstream (slot id → board yard label). + // Their cargo renders as TO LOAD AT and stays out of the loaded tallies. + pendingBoardYardLabelBySlot?: Map; + // Intercity (Marshalling 2) only: couples/switches logged at the stop + // this document is printed at (see ScheduleWagonAdjustmentLog). Origin + // import/export docs never pass this, so they render no such box. + consistChangesAtStop?: ScheduleWagonAdjustmentLog[]; }, ): string { const esc = (value: unknown) => @@ -3565,6 +3711,7 @@ export class TrainSchedulingService { `, ]; } + const pendingAt = opts?.pendingBoardYardLabelBySlot?.get(wagon.id); return allocations.map((allocation) => { const booking = allocation.booking ?? bookingById.get(allocation.bookingId); const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType; @@ -3576,7 +3723,7 @@ export class TrainSchedulingService { const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', '); return ` ${wagonCells} - ${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)} + ${pendingAt ? `TO LOAD AT ${esc(pendingAt).toUpperCase()} — ` : ''}${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)} ${esc(companyName)} ${esc(containerNumbers || firstContainer?.containerNumber)} ${esc(chassisNumbers)} @@ -3613,18 +3760,27 @@ export class TrainSchedulingService { (wagon.allocations ?? []).length === 0 && !emptiesByWagon.get(Number(wagon.sequenceNo))?.length, ).length; + const loadsHere = (wagon: TrainSetWagon) => !opts?.pendingBoardYardLabelBySlot?.get(wagon.id); const totalWeight = wagons.reduce( (sum, wagon) => - sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0), + sum + + (loadsHere(wagon) + ? (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0) + : 0), 0, ); // Container count summary (40ft, 20ft) — empties returning to Djibouti are // physically on the train, so they count, and are called out on their own tile. - let count40ft = 0, count20ft = 0; + // Cargo boarding downstream is not on this train yet — it tallies separately. + let count40ft = 0, count20ft = 0, pendingContainers = 0; wagons.forEach((wagon) => { (wagon.allocations ?? []).forEach((allocation) => { (allocation.containerItems ?? []).forEach((item) => { + if (!loadsHere(wagon)) { + pendingContainers++; + return; + } const size = this.resolveContainerItemSize(item); if (size === 40) count40ft++; else if (size === 20) count20ft++; @@ -3691,6 +3847,7 @@ export class TrainSchedulingService {
Containers 40ft${esc(count40ft)}
Containers 20ft${esc(count20ft)}
Total containers${esc(count40ft + count20ft)}
+ ${pendingContainers ? `
To load en route${esc(pendingContainers)} containers
` : ''} ${emptyContainers.length ? `
Empty containers${esc(emptyContainers.length)}
` : ''}
Prepared person${esc(schedule.preparedByUserId)}
Check person${esc(schedule.checkedByUserId)}
@@ -3701,6 +3858,24 @@ export class TrainSchedulingService { ${opts?.positionLabel ? `
Current position${esc(opts.positionLabel)}
` : ''} + ${ + opts?.consistChangesAtStop?.length + ? `
+ Consist changed at this stop: + ${(() => { + const coupled = opts.consistChangesAtStop.filter((row) => row.action === 'ADD'); + const switched = opts.consistChangesAtStop.filter((row) => row.action === 'SWITCH'); + return [ + coupled.length ? `Coupled: ${esc(coupled.map((row) => row.wagonNumber).join(', '))}` : '', + switched.length ? `Uncoupled — replaced: ${esc(switched.map((row) => row.wagonNumber).join(', '))}` : '', + ] + .filter(Boolean) + .join('  |  '); + })()} +
` + : '' + } + @@ -3864,19 +4039,30 @@ export class TrainSchedulingService { .replace(/'/g, '''); const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleString('en-GB') : '-'); const status = loadList.operation.status; + // A leg slot (boardYard set) couples mid-corridor — its cargo is NOT on the + // physical train this Djibouti-side document is checked against, so it must + // stay out of the loaded tallies or the gate count stops matching. + const loadsHere = (wagon: (typeof loadList.wagons)[number]) => !wagon.boardYard; const totalAllocations = loadList.wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0); const totalWeight = loadList.wagons.reduce( (sum, wagon) => - sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0), + sum + + (loadsHere(wagon) + ? wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0) + : 0), 0, ); const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length; - // Container count summary (40ft, 20ft) - let count40ft = 0, count20ft = 0; + // Container count summary (40ft, 20ft) — loaded at origin vs. en route + let count40ft = 0, count20ft = 0, pendingContainers = 0; loadList.wagons.forEach((wagon) => { wagon.allocations.forEach((allocation) => { (allocation.containerItems ?? []).forEach((item) => { + if (!loadsHere(wagon)) { + pendingContainers++; + return; + } const size = this.resolveContainerItemSize(item); if (size === 40) count40ft++; else if (size === 20) count20ft++; @@ -3891,8 +4077,8 @@ export class TrainSchedulingService { - - `; + + `; // An empty wagon still runs in the consist, so it still gets a line — see // buildExportLoadListHtml. if (wagon.allocations.length === 0) { @@ -3917,7 +4103,7 @@ export class TrainSchedulingService { - + `; }, @@ -3990,6 +4176,7 @@ export class TrainSchedulingService {
Containers 40ft${esc(count40ft)}
Containers 20ft${esc(count20ft)}
Total containers${esc(count40ft + count20ft)}
+ ${pendingContainers ? `
To load en route${esc(pendingContainers)} containers
` : ''}
Gatepass granted${esc(date(loadList.operation.gatepassGrantedAt))}
@@ -4334,6 +4521,48 @@ export class TrainSchedulingService { } /** Track payload for a schedule: ordered stations, logged checkpoints, current position. */ + /** Attach `startedByName` / `endedByName` to each work-window phase (one iam lookup). */ + private async stationWorkLogsWithNames( + logs: TrainSchedule['stationWorkLogs'], + ): Promise> { + const workLogs = logs ?? {}; + const userIds = [ + ...new Set( + Object.values(workLogs) + .flatMap((log) => [ + log.loading?.startedByUserId, + log.loading?.endedByUserId, + log.unloading?.startedByUserId, + log.unloading?.endedByUserId, + ]) + .filter((id): id is string => Boolean(id)), + ), + ]; + const rows: Array<{ id: string; name: string | null }> = userIds.length + ? await this.dataSource.query( + `SELECT id, COALESCE(username, email) AS name FROM iam.users WHERE id = ANY($1::uuid[])`, + [userIds], + ) + : []; + const nameById = new Map(rows.map((r) => [r.id, r.name])); + const withNames = (phase?: StationWorkPhaseLog) => + phase + ? { + ...phase, + startedByName: phase.startedByUserId + ? nameById.get(phase.startedByUserId) ?? null + : null, + endedByName: phase.endedByUserId ? nameById.get(phase.endedByUserId) ?? null : null, + } + : undefined; + return Object.fromEntries( + Object.entries(workLogs).map(([yardId, log]) => [ + yardId, + { loading: withNames(log.loading), unloading: withNames(log.unloading) }, + ]), + ); + } + async getScheduleCheckpoints(scheduleId: string) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { @@ -4374,6 +4603,10 @@ export class TrainSchedulingService { origin: stations[0]?.label ?? null, destination: stations[stations.length - 1]?.label ?? null, stations, + // Per-yard loading/unloading time windows for the track page's + // start/end buttons and elapsed-time display — with the recorder's + // display name resolved so staff see WHO started/ended each window. + stationWorkLogs: await this.stationWorkLogsWithNames(schedule.stationWorkLogs), currentSequenceNo, checkpoints: events.map((e) => ({ id: e.id, @@ -4848,6 +5081,15 @@ export class TrainSchedulingService { if (schedule.status !== TrainScheduleStatusEnum.Dispatched) { throw new BadRequestException('Only DISPATCHED trains can arrive'); } + // Arrival happens BEFORE unloading: the train is marked arrived whenever + // it physically gets there, and the destination's unloading window opens + // afterwards. The bulk booking sweep (autoArriveAtFinalYard) only runs + // when that window is already open — otherwise final-yard bookings stay + // IN_TRANSIT and are unloaded per booking once staff start unloading + // (the per-booking endpoint enforces the window itself). + const destinationUnloadingStarted = Boolean( + schedule.stationWorkLogs?.[schedule.destinationStationId]?.unloading?.startedAt, + ); // The arrival clock: the operator's entered time when arriving via the final // checkpoint (already order/future-checked there), else now. @@ -4860,7 +5102,7 @@ export class TrainSchedulingService { { actualArrivalAt: now, // Freeze the plan before the wagons below are released to their yards. - wagonAllocationSnapshot: this.buildWagonAllocationSnapshot( + wagonAllocationSnapshot: await this.buildWagonAllocationSnapshot( schedule, TrainScheduleStatusEnum.Arrived, now, @@ -4888,7 +5130,12 @@ export class TrainSchedulingService { // operator didn't unload individually get their arrival stamped now as a // bulk fallback. Mid-corridor bookings are NOT touched — their arrival is // their own unload (possibly already done while the train kept rolling). - await this.bookingJourneyService.autoArriveAtFinalYard(manager, schedule, now); + // Runs only when the destination's unloading window is already open — + // otherwise arrival precedes unloading and staff unload per booking + // after starting the window. + if (destinationUnloadingStarted) { + await this.bookingJourneyService.autoArriveAtFinalYard(manager, schedule, now); + } // Release every locomotive of the set (not just the legacy primary) and move it // to the destination yard where it physically arrived. @@ -5234,7 +5481,7 @@ export class TrainSchedulingService { bookingWindowStatus: 'CLOSED', windowPhase: 'DONE', // Freeze the plan before the wagons below are released back to the yard. - wagonAllocationSnapshot: this.buildWagonAllocationSnapshot( + wagonAllocationSnapshot: await this.buildWagonAllocationSnapshot( schedule, TrainScheduleStatusEnum.Cancelled, now, @@ -6021,11 +6268,11 @@ export class TrainSchedulingService { * physical wagons, so the historical allocation survives those wagons being * re-pinned onto later trains. `capturedStatus` is the status being applied. */ - private buildWagonAllocationSnapshot( + private async buildWagonAllocationSnapshot( schedule: TrainSchedule, capturedStatus: TrainScheduleStatusEnum, capturedAt: Date, - ): WagonAllocationSnapshot { + ): Promise { const slots = [...(schedule.trainSet?.wagons ?? [])] .sort((a, b) => a.sequenceNo - b.sequenceNo) .map((wagon) => ({ @@ -6049,10 +6296,41 @@ export class TrainSchedulingService { })), })); + // A built train hauls EVERY coupled wagon, empties included. After this + // transition the physical wagons are released and re-pinned to later + // trains, so capture the empty consist here — it is the only durable + // record of which empties rode this departure (history + yard tracking). + const coveredPhysicalIds = new Set( + slots.map((slot) => slot.physicalWagonId).filter(Boolean), + ); + const trainWagons = schedule.trainSet?.trainId + ? await this.dataSource.getRepository(Wagon).find({ + where: { trainId: schedule.trainSet.trainId }, + relations: { wagonType: true }, + order: { sequenceNumber: 'ASC' }, + }) + : []; + const emptyConsistWagons = trainWagons + .filter((wagon) => !coveredPhysicalIds.has(wagon.id)) + .map((wagon, index) => ({ + physicalWagonId: wagon.id, + physicalWagonNumber: wagon.wagonNumber ?? null, + sequenceNo: wagon.sequenceNumber ?? slots.length + index + 1, + wagonTypeId: wagon.wagonTypeId ?? null, + wagonTypeCode: wagon.wagonType?.code ?? null, + wagonTypeName: wagon.wagonType?.name ?? null, + capacityTons: Number(wagon.wagonType?.capacityTons ?? 0), + tareWeightTons: wagon.wagonType + ? Number(wagon.wagonType.tareWeightTons) + : null, + lengthMeters: Number(wagon.wagonType?.lengthMeters ?? 0), + })); + return { capturedStatus, capturedAt: capturedAt.toISOString(), slots, + emptyConsistWagons, }; } @@ -9358,6 +9636,8 @@ export class TrainSchedulingService { Booking, | 'freightType' | 'cargoTotalWeightVgm' + | 'bulkTotalWeightTons' + | 'bulkRequestedWagons' | 'wagonsRequired' | 'bookingContainers' | 'cargoType' @@ -9388,7 +9668,7 @@ export class TrainSchedulingService { const byLength = containerWagonsForLines(booking.bookingContainers ?? []); // PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T // wagon) — more wagons for the same cargo, so more tare to pull. - const tonsPerWagon = bulkTonsPerWagon(booking.cargoType, wagonTypeId, dims.capacityTons); + const tonsPerWagon = bulkTonsPerWagonFor(booking, booking.cargoType, wagonTypeId, dims.capacityTons); const byWeight = cargo > 0 && tonsPerWagon > 0 ? Math.ceil(cargo / tonsPerWagon) : 0; // Break-bulk (PER_ITEM): indivisible items occupy more wagons than raw // tonnage suggests — their tare must be pulled too (batch dimsFor parity). @@ -9520,7 +9800,36 @@ export class TrainSchedulingService { 0, ...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo), ); - const emptyConsistWagons = rawConsistWagons + // Frozen schedules: the live wagon↔train joins no longer describe this + // departure, so the empty consist is read from the snapshot captured at + // dispatch/arrival — that keeps "which wagons ran empty" in the history + // views. Snapshots from before empties were recorded simply have none. + const frozenEmptyConsistWagons = (snapshot?.emptyConsistWagons ?? []).map( + (wagon) => ({ + id: wagon.physicalWagonId, + sequenceNo: wagon.sequenceNo, + capacityTons: roundTons(wagon.capacityTons), + lengthMeters: roundTons(wagon.lengthMeters), + assignedWeightTons: 0, + tareWeightTons: + wagon.tareWeightTons != null ? roundTons(wagon.tareWeightTons) : null, + status: 'EMPTY', + boardYardId: null, + alightYardId: null, + physicalWagonId: wagon.physicalWagonId, + physicalWagonNumber: wagon.physicalWagonNumber, + wagonType: wagon.wagonTypeId + ? { + id: wagon.wagonTypeId, + code: wagon.wagonTypeCode ?? '', + name: wagon.wagonTypeName ?? '', + } + : null, + allocations: [], + consistOnly: true, + }), + ); + const liveEmptyConsistWagons = rawConsistWagons .filter((wagon) => !coveredPhysicalIds.has(wagon.id)) .map((wagon, index) => ({ // Physical wagon id — there is no TrainSetWagon slot behind this @@ -9553,6 +9862,9 @@ export class TrainSchedulingService { allocations: [], consistOnly: true, })); + const emptyConsistWagons = isWagonAllocationFrozen + ? frozenEmptyConsistWagons + : liveEmptyConsistWagons; // The consist is DRAWN in the built train's real coupling order (rawConsistWagons // is already ASC/DESC per reverseWagonOrder), not in slot order — see @@ -9866,10 +10178,17 @@ export class TrainSchedulingService { loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded, wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId), isGovernment: Boolean(sb.booking?.isGovernment), + // Shipping-line bookings never prepay (credit ledger) — the dispatch + // dialog needs this to know FULLY_EXECUTED means boardable for them. + shippingLineCompanyId: sb.booking?.shippingLineCompanyId ?? null, })) ?? [], // Ordered corridor stops (route milestones; falls back to the two // endpoints) — lets the UI draw per-segment occupancy and label legs. stops: this.mapScheduleStops(schedule), + // Per-yard loading/unloading time windows (start/end clicks) — the + // detail page shows the origin's loading window; dispatch requires it + // started when cargo boards there. + stationWorkLogs: schedule.stationWorkLogs ?? {}, // Gross ceiling the validator holds each leg to: the set's weakest // locomotive pull limit plus its overage tolerance. Booking weightTons // above are gross too, so the strip can sum them per leg against this. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts index ed476a6e1..05f386a4c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts @@ -5,6 +5,7 @@ import { bulkItemWagonsForAllowedTypes, bulkItemWagonsRequired, bulkTonsPerWagon, + bulkTonsPerWagonFor, bulkTonWagonsForAllowedTypes, bulkTonWagonsRequired, bulkWagonsForAllowedTypes, @@ -177,6 +178,19 @@ describe('train-capacity.util', () => { expect(bulkTonWagonsForAllowedTypes(bulk(200), cargoType, 70)).toBe(3); }); + it('NUMBER_OF_WAGONS: a requested count wins over the tonnage-derived one', () => { + const req = { ...bulk(100), bulkRequestedWagons: 40 }; + // 100T on 70T wagons is 2 by tonnage — the customer asked for 40. + expect(bulkTonWagonsRequired(req, null, 'nw5', 70)).toBe(40); + expect(bulkWagonsForAllowedTypes(req, { wagonTypes: [{ id: 'nw5', capacityTons: 70 }] }, 70)).toBe(40); + // Each wagon then carries the even share, not rated capacity. + expect(bulkTonsPerWagonFor(req, null, 'nw5', 70)).toBe(2.5); + // ceil(tons / evenShare) must land exactly on the requested count. + const awkward = { ...bulk(100), bulkRequestedWagons: 3 }; + const share = bulkTonsPerWagonFor(awkward, null, 'nw5', 70); + expect(Math.ceil(100 / share)).toBe(3); + }); + it('routes PER_ITEM and PER_TON through one call', () => { expect(bulkWagonsForAllowedTypes(bulk(200), sugar, 70)).toBe(4); // PER_ITEM still wins where an item count is present. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts index 63feb6e8c..8b980be11 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -114,6 +114,43 @@ export function bookingCargoTons(booking: { ); } +/** + * Customer-requested wagon count of a NUMBER_OF_WAGONS bulk booking; 0 when + * the booking carries none (every other cargo unit). The request was validated + * against wagon capacity at booking creation, so sizing code honours it + * verbatim instead of deriving a count from tonnage. + */ +export function requestedBulkWagons(booking: { + bulkRequestedWagons?: number | string | null; +}): number { + const n = Math.floor(num(booking.bulkRequestedWagons)); + return n > 0 ? n : 0; +} + +/** + * Booking-aware {@link bulkTonsPerWagon}: a NUMBER_OF_WAGONS booking fixed its + * wagon count, so each wagon carries tons ÷ requested (the even spread the + * customer asked for), never more. Rounded UP to 3 decimals so + * ceil(tons / perWagon) lands exactly on the requested count instead of one + * over on float error. Other bookings get the cargo-type figure unchanged. + */ +export function bulkTonsPerWagonFor( + booking: Parameters[0] & { + bulkRequestedWagons?: number | string | null; + }, + cargoType: ItemFitCargoType | undefined, + wagonTypeId: string | null | undefined, + capacityTons: number | string | null | undefined, +): number { + const base = bulkTonsPerWagon(cargoType, wagonTypeId, capacityTons); + const requested = requestedBulkWagons(booking); + if (!requested) return base; + const tons = bookingCargoTons(booking); + if (!(tons > 0)) return base; + const evenShare = Math.ceil((tons / requested) * 1000) / 1000; + return base > 0 ? Math.min(base, evenShare) : evenShare; +} + /** * Wagons a break-bulk (PER_ITEM) bulk booking needs. Items are indivisible, so * floor how many whole items fit one wagon, then ceil the wagon count: @@ -184,11 +221,16 @@ export function bulkTonsPerWagon( * usable per-wagon figure, so callers can fall back as before. */ export function bulkTonWagonsRequired( - booking: Parameters[0], + booking: Parameters[0] & { + bulkRequestedWagons?: number | string | null; + }, cargoType: ItemFitCargoType | undefined, wagonTypeId: string | null | undefined, capacityTons: number | string | null | undefined, ): number { + // NUMBER_OF_WAGONS: the customer fixed the count — honour it verbatim. + const requested = requestedBulkWagons(booking); + if (requested) return requested; const perWagon = bulkTonsPerWagon(cargoType, wagonTypeId, capacityTons); const tons = bookingCargoTons(booking); if (!(perWagon > 0) || !(tons > 0)) return 0; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts index 2df626a84..b4e87d398 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts @@ -7,7 +7,7 @@ import { bookingCargoTons, bulkItemsFitFor, bulkItemWagonsRequired, - bulkTonsPerWagon, + bulkTonsPerWagonFor, bulkTonWagonsRequired, consistViolations, } from '../train-capacity.util'; @@ -193,8 +193,11 @@ export function buildBulkWagonPlan( // pool with uncapped tonnage either: its wagons stop at the cap, so 200T needs // 4 wagons and pooling it at 70T would plan 3. Capped bookings are sized on // their own cap; only genuinely uncapped tonnage pools at rated capacity. + // A NUMBER_OF_WAGONS booking is "capped" at its even share (tons ÷ requested), + // so it plans exactly the requested count. const cappedTonSlotsByBooking = bookings.map((b, i) => - itemSlotsByBooking[i] > 0 || bulkTonsPerWagon(b.cargoType, wagonType.id, capacity) >= capacity + itemSlotsByBooking[i] > 0 || + bulkTonsPerWagonFor(b, b.cargoType, wagonType.id, capacity) >= capacity ? 0 : bulkTonWagonsRequired(b, b.cargoType, wagonType.id, capacity), ); @@ -330,6 +333,7 @@ function allocateBookingsToSlots( // bookings that column is an item COUNT, not tons. remainingWeightTons: roundTons(bookingCargoTons(booking)), cargoType: booking.cargoType, + booking, })); let bookingIndex = 0; @@ -343,10 +347,17 @@ function allocateBookingsToSlots( const booking = remaining[bookingIndex]; // A PER_TON loading cap (sugar 50T on a 70T wagon) binds the FILL as well // as the wagon count — the plan reserved a wagon per capped chunk, so - // pouring rated capacity into it would leave the last wagon empty. + // pouring rated capacity into it would leave the last wagon empty. A + // NUMBER_OF_WAGONS booking fills each wagon its even share (tons ÷ + // requested) for the same reason. const takeCap = Math.min( wagonRemaining, - bulkTonsPerWagon(booking.cargoType, slot.wagonTypeId, slot.capacityTons), + bulkTonsPerWagonFor( + booking.booking, + booking.cargoType, + slot.wagonTypeId, + slot.capacityTons, + ), ); const allocatedWeightTons = roundTons( Math.min(takeCap, booking.remainingWeightTons), diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts index 449033bfc..92e4b441d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -6,6 +6,7 @@ import { bookingCargoTons, bulkItemsFitFor, bulkTonsPerWagon, + bulkTonsPerWagonFor, bulkWagonsForAllowedTypes, } from './train-capacity.util'; import { @@ -179,7 +180,7 @@ const shortageFor = ( let seatable = 0; let usedWagons = 0; for (const { wt, free } of freeByType) { - const perWagon = bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons)); + const perWagon = bulkTonsPerWagonFor(booking, booking.cargoType, wt.id, Number(wt.capacityTons)); if (!(perWagon > 0) || free <= 0) continue; seatable += free * perWagon; usedWagons += free; @@ -188,7 +189,7 @@ const shortageFor = ( const bestPerWagon = Math.max( 1, ...candidates.map((wt) => - bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons)), + bulkTonsPerWagonFor(booking, booking.cargoType, wt.id, Number(wt.capacityTons)), ), ); return { @@ -583,7 +584,8 @@ export function planWagonsWithStock(params: { if (perItem ? remainingItems <= 0 : remainingWeight <= 0) break; const wagonType = candidates.find((wt) => wt.id === open.slot.wagonTypeId); if (!wagonType) continue; - const room = bulkTonsPerWagon( + const room = bulkTonsPerWagonFor( + booking, booking.cargoType, open.slot.wagonTypeId, Number(open.slot.capacityTons), @@ -640,7 +642,20 @@ export function planWagonsWithStock(params: { openedSlot.freeItems = itemBudgetOf(openedSlot) - takeItems; remainingItems -= takeItems; } else { - take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight)); + // NUMBER_OF_WAGONS: each wagon takes the even share (tons / requested), + // not the full per-wagon cap — the loop then opens exactly that count. + take = roundTons( + Math.min( + openedSlot.freeCapacityTons, + bulkTonsPerWagonFor( + booking, + booking.cargoType, + openedSlot.slot.wagonTypeId, + openedSlot.slot.capacityTons, + ), + remainingWeight, + ), + ); } addAllocation( openedSlot.slot, diff --git a/apps/edr-freight-api/src/modules/trains/dto/wagon-detach-request.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/wagon-detach-request.dto.ts new file mode 100644 index 000000000..afe219bfa --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/dto/wagon-detach-request.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; + +import { WagonDetachRequestAction } from '../entities/wagon-detach-request.entity'; + +export class CreateWagonDetachRequestDto { + @ApiProperty({ + enum: WagonDetachRequestAction, + description: 'What approval is being asked for: a plain detach, or detach + MAINTENANCE.', + }) + @IsEnum(WagonDetachRequestAction) + action!: WagonDetachRequestAction; + + @ApiProperty({ + description: 'Why the wagon must leave the scheduled consist. Shown to the approver.', + maxLength: 500, + }) + @IsString() + @IsNotEmpty() + @MaxLength(500) + reason!: string; +} + +export class DecideWagonDetachRequestDto { + @ApiPropertyOptional({ + description: 'Decision note — required when rejecting, optional when approving.', + maxLength: 500, + }) + @IsOptional() + @IsString() + @MaxLength(500) + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/trains/entities/wagon-detach-request.entity.ts b/apps/edr-freight-api/src/modules/trains/entities/wagon-detach-request.entity.ts new file mode 100644 index 000000000..acaaed464 --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/entities/wagon-detach-request.entity.ts @@ -0,0 +1,69 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +export enum WagonDetachRequestAction { + Detach = 'DETACH', + Maintenance = 'MAINTENANCE', +} + +export enum WagonDetachRequestStatus { + Pending = 'PENDING', + Approved = 'APPROVED', + Rejected = 'REJECTED', +} + +/** + * Approval gate for detaching a wagon (or sending it to maintenance) from a + * train that is on a SCHEDULED run. + * + * A draft-schedule or unscheduled train is edited freely; once the run is + * SCHEDULED, pulling a wagon out changes a departure customers already booked + * against, so it becomes a two-person action: one staffer requests with a + * reason, another (holding trains:approve_wagon_detach) approves — approval + * executes the detach immediately. Rows are never deleted: decided rows are + * the audit trail of who asked, who decided, and why. + */ +@Entity({ schema: 'freight', name: 'wagon_detach_requests' }) +@Index(['trainId']) +@Index(['trainId', 'status']) +export class WagonDetachRequest extends BaseEntity { + @Column({ name: 'train_id', type: 'uuid' }) + trainId!: string; + + @Column({ name: 'wagon_id', type: 'uuid' }) + wagonId!: string; + + /** Snapshot — the audit trail must read correctly if the wagon is renumbered or deleted. */ + @Column({ name: 'wagon_number', type: 'varchar', length: 50 }) + wagonNumber!: string; + + @Column({ name: 'action', type: 'varchar', length: 20 }) + action!: WagonDetachRequestAction; + + @Column({ name: 'reason', type: 'varchar', length: 500 }) + reason!: string; + + @Column({ + name: 'status', + type: 'enum', + enum: WagonDetachRequestStatus, + enumName: 'wagon_detach_requests_status_enum', + default: WagonDetachRequestStatus.Pending, + }) + status!: WagonDetachRequestStatus; + + /** IAM user id of the requester. The approver must be a different person. */ + @Column({ name: 'requested_by', type: 'uuid', nullable: true }) + requestedBy?: string | null; + + /** IAM user id of the approver/rejecter; null while pending. */ + @Column({ name: 'decided_by', type: 'uuid', nullable: true }) + decidedBy?: string | null; + + @Column({ name: 'decided_at', type: 'timestamptz', nullable: true }) + decidedAt?: Date | null; + + /** Required on reject, optional on approve. */ + @Column({ name: 'decision_note', type: 'varchar', length: 500, nullable: true }) + decisionNote?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts index 21431a1a4..46c95de95 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts @@ -25,6 +25,10 @@ import { BuildTrainDto } from './dto/build-train.dto'; import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto'; import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto'; import { SendWagonToMaintenanceDto } from './dto/send-wagon-to-maintenance.dto'; +import { + CreateWagonDetachRequestDto, + DecideWagonDetachRequestDto, +} from './dto/wagon-detach-request.dto'; import { UpdateTrainDetailsDto } from './dto/update-train-details.dto'; import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto'; import { UpdateTrainYardDto } from './dto/update-train-yard.dto'; @@ -47,6 +51,7 @@ import { TrainBuilderService } from './train-builder.service'; FREIGHT_PERMS.trains.changeWagonYard, FREIGHT_PERMS.trains.toggleActive, FREIGHT_PERMS.trains.disband, + FREIGHT_PERMS.trains.approveWagonDetach, ]) export class TrainBuilderController { constructor(private readonly trainBuilderService: TrainBuilderService) {} @@ -206,6 +211,74 @@ export class TrainBuilderController { ); } + @Get(':id/detach-requests') + @ApiOperation({ + summary: + 'Detach/maintenance approval requests of this train, newest first — pending and decided alike (the audit trail)', + }) + detachRequests(@Param('id', ParseUUIDPipe) id: string) { + return this.trainBuilderService.listDetachRequests(id); + } + + @Post(':id/wagons/:wagonId/detach-requests') + @FleetManage(FREIGHT_PERMS.trains.assignWagons) + @ApiOperation({ + summary: + 'Request approval to detach a wagon (or send it to maintenance) while the train is on a SCHEDULED run', + }) + createDetachRequest( + @Param('id', ParseUUIDPipe) id: string, + @Param('wagonId', ParseUUIDPipe) wagonId: string, + @Body() dto: CreateWagonDetachRequestDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.trainBuilderService.createDetachRequest( + id, + wagonId, + dto, + resolveAuthUserId(user), + ); + } + + @Post(':id/detach-requests/:requestId/approve') + @FleetManage(FREIGHT_PERMS.trains.approveWagonDetach) + @ApiOperation({ + summary: + 'Approve a detach/maintenance request — the detach executes immediately; the approver must not be the requester', + }) + approveDetachRequest( + @Param('id', ParseUUIDPipe) id: string, + @Param('requestId', ParseUUIDPipe) requestId: string, + @CurrentUser() user: AuthUserPayload, + @Body() dto?: DecideWagonDetachRequestDto, + ) { + return this.trainBuilderService.decideDetachRequest( + id, + requestId, + 'APPROVE', + resolveAuthUserId(user), + dto?.note, + ); + } + + @Post(':id/detach-requests/:requestId/reject') + @FleetManage(FREIGHT_PERMS.trains.approveWagonDetach) + @ApiOperation({ summary: 'Reject a detach/maintenance request — a note explaining why is required' }) + rejectDetachRequest( + @Param('id', ParseUUIDPipe) id: string, + @Param('requestId', ParseUUIDPipe) requestId: string, + @CurrentUser() user: AuthUserPayload, + @Body() dto: DecideWagonDetachRequestDto, + ) { + return this.trainBuilderService.decideDetachRequest( + id, + requestId, + 'REJECT', + resolveAuthUserId(user), + dto.note, + ); + } + @Post(':id/reorder-wagons') @FleetManage(FREIGHT_PERMS.trains.assignWagons) @ApiOperation({ summary: 'Persist a drag-reorder of the full consist' }) diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index d7cfc29c5..09f6559f7 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -30,8 +30,14 @@ import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto'; import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto'; import { UpdateTrainDetailsDto } from './dto/update-train-details.dto'; import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto'; +import { CreateWagonDetachRequestDto } from './dto/wagon-detach-request.dto'; import { TrainLocomotive } from './entities/train-locomotive.entity'; import { Train } from './entities/train.entity'; +import { + WagonDetachRequest, + WagonDetachRequestAction, + WagonDetachRequestStatus, +} from './entities/wagon-detach-request.entity'; import { buildPaginationMeta, normalizePagination, @@ -751,32 +757,43 @@ export class TrainBuilderService { /** Detach one wagon and close the sequence gap it leaves. */ async removeWagon(id: string, wagonId: string, userId?: string | null) { const pending = await this.dataSource.transaction(async (manager) => { - const train = await this.getEditableTrain(manager, id); - const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); - if (!wagon || wagon.trainId !== train.id) { - throw new NotFoundException(`Wagon ${wagonId} is not part of this train`); - } - await this.assertDetachableAndReleaseStaleSlots(manager, wagon); - await manager.getRepository(Wagon).update(wagon.id, { - trainId: null, - sequenceNumber: null, - status: WagonStatus.Available, - importTrainNumber: null, - exportTrainNumber: null, - }); - await this.resequenceWagons(manager, train.id); - return this.syncLiveScheduleAfterConsistChange( - manager, - train.id, - [{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }], - userId ?? null, - wagon.currentYardId ?? train.currentYardId ?? null, - ); + await this.assertDetachNeedsNoApproval(manager, id); + return this.removeWagonCore(manager, id, wagonId, userId); }); await this.reconcileWindowAfterConsistChange(pending); return this.getComposition(id); } + /** Transactional body of removeWagon — also runs under an approved detach request. */ + private async removeWagonCore( + manager: EntityManager, + id: string, + wagonId: string, + userId?: string | null, + ): Promise { + const train = await this.getEditableTrain(manager, id); + const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); + if (!wagon || wagon.trainId !== train.id) { + throw new NotFoundException(`Wagon ${wagonId} is not part of this train`); + } + await this.assertDetachableAndReleaseStaleSlots(manager, wagon); + await manager.getRepository(Wagon).update(wagon.id, { + trainId: null, + sequenceNumber: null, + status: WagonStatus.Available, + importTrainNumber: null, + exportTrainNumber: null, + }); + await this.resequenceWagons(manager, train.id); + return this.syncLiveScheduleAfterConsistChange( + manager, + train.id, + [{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }], + userId ?? null, + wagon.currentYardId ?? train.currentYardId ?? null, + ); + } + /** * Detach one wagon AND flag it for maintenance: it leaves the consist and * moves to MAINTENANCE status (not AVAILABLE), so it is not re-coupled until @@ -789,6 +806,22 @@ export class TrainBuilderService { note?: string | null, ) { const pending = await this.dataSource.transaction(async (manager) => { + await this.assertDetachNeedsNoApproval(manager, id); + return this.sendWagonToMaintenanceCore(manager, id, wagonId, userId, note); + }); + await this.reconcileWindowAfterConsistChange(pending); + return this.getComposition(id); + } + + /** Transactional body of sendWagonToMaintenance — also runs under an approved request. */ + private async sendWagonToMaintenanceCore( + manager: EntityManager, + id: string, + wagonId: string, + userId?: string | null, + note?: string | null, + ): Promise { + { const train = await this.getEditableTrain(manager, id); const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); if (!wagon || wagon.trainId !== train.id) { @@ -851,6 +884,191 @@ export class TrainBuilderService { userId ?? null, yardId, ); + } + } + + /** + * Direct-detach guard: while this train carries a live SCHEDULED run, + * removing a wagon changes a departure customers already booked against, so + * it is a two-person action — refuse here and point at the request flow. + * DRAFT stays freely editable; DISPATCHED is already frozen by + * getEditableTrain (the train is IN_SERVICE). + */ + private async assertDetachNeedsNoApproval( + manager: EntityManager, + trainId: string, + ): Promise { + const scheduled = await this.findScheduledRun(manager, trainId); + if (scheduled) { + throw new ConflictException( + `Train is on scheduled run ${scheduled.reference ?? scheduled.id} — detaching a wagon needs an approved detach request`, + ); + } + } + + private async findScheduledRun( + manager: EntityManager, + trainId: string, + ): Promise<{ id: string; reference: string | null } | null> { + const rows: { id: string; reference: string | null }[] = await manager.query( + `SELECT ts.id, ts.reference + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + WHERE tset.train_id = $1 + AND ts.status = 'SCHEDULED' + AND ts.deleted_at IS NULL + LIMIT 1`, + [trainId], + ); + return rows[0] ?? null; + } + + /** + * File a detach/maintenance approval request for a wagon on a SCHEDULED + * train. The request carries the reason; a different staffer with + * trains:approve_wagon_detach decides it (approval executes the detach). + */ + async createDetachRequest( + id: string, + wagonId: string, + dto: CreateWagonDetachRequestDto, + userId?: string | null, + ) { + return this.dataSource.transaction(async (manager) => { + const train = await this.getEditableTrain(manager, id); + const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); + if (!wagon || wagon.trainId !== train.id) { + throw new NotFoundException(`Wagon ${wagonId} is not part of this train`); + } + const scheduled = await this.findScheduledRun(manager, train.id); + if (!scheduled) { + throw new ConflictException( + 'This train has no SCHEDULED run — detach the wagon directly, no approval needed', + ); + } + // Refuse up front what an approval could never execute (booked + // allocations pin the wagon) — but release nothing yet: slots are only + // touched when the approved detach actually runs. + await this.assertDetachableAndReleaseStaleSlots(manager, wagon, { checkOnly: true }); + const repo = manager.getRepository(WagonDetachRequest); + const open = await repo.findOne({ + where: { trainId: train.id, wagonId: wagon.id, status: WagonDetachRequestStatus.Pending }, + }); + if (open) { + throw new ConflictException( + `Wagon ${wagon.wagonNumber} already has a pending detach request`, + ); + } + return repo.save( + repo.create({ + trainId: train.id, + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + action: dto.action, + reason: dto.reason.trim(), + requestedBy: userId ?? null, + }), + ); + }); + } + + /** All detach/maintenance requests of this train, newest first — the approval audit trail. */ + async listDetachRequests(trainId: string) { + const rows: Array<{ + id: string; + wagonId: string; + wagonNumber: string; + action: string; + reason: string; + status: string; + requestedById: string | null; + requestedBy: string | null; + requestedAt: Date; + decidedBy: string | null; + decidedAt: Date | null; + decisionNote: string | null; + }> = await this.dataSource.query( + `SELECT r.id, + r.wagon_id AS "wagonId", + r.wagon_number AS "wagonNumber", + r.action, + r.reason, + r.status, + r.requested_by AS "requestedById", + COALESCE(ru.username, ru.email) AS "requestedBy", + r.created_at AS "requestedAt", + COALESCE(du.username, du.email) AS "decidedBy", + r.decided_at AS "decidedAt", + r.decision_note AS "decisionNote" + FROM freight.wagon_detach_requests r + LEFT JOIN iam.users ru ON ru.id = r.requested_by + LEFT JOIN iam.users du ON du.id = r.decided_by + WHERE r.train_id = $1 + AND r.deleted_at IS NULL + ORDER BY r.created_at DESC + LIMIT 100`, + [trainId], + ); + return rows; + } + + /** + * Decide a pending request. Approve executes the detach (or maintenance + * move) in the same transaction that stamps the decision, so an approved row + * can never exist without its detach having happened. The requester cannot + * approve their own request; a rejection must carry a note. + */ + async decideDetachRequest( + id: string, + requestId: string, + decision: 'APPROVE' | 'REJECT', + userId?: string | null, + note?: string | null, + ) { + const pending = await this.dataSource.transaction(async (manager) => { + const repo = manager.getRepository(WagonDetachRequest); + const request = await repo.findOne({ + where: { id: requestId, trainId: id }, + lock: { mode: 'pessimistic_write' }, + }); + if (!request) { + throw new NotFoundException(`Detach request ${requestId} not found on this train`); + } + if (request.status !== WagonDetachRequestStatus.Pending) { + throw new ConflictException( + `This request was already ${request.status.toLowerCase()}`, + ); + } + const decisionNote = note?.trim() || null; + if (decision === 'REJECT') { + if (!decisionNote) { + throw new BadRequestException('A note explaining the rejection is required'); + } + await repo.update(request.id, { + status: WagonDetachRequestStatus.Rejected, + decidedBy: userId ?? null, + decidedAt: new Date(), + decisionNote, + }); + return null; + } + // The 4-eyes point of the gate: requester and approver are different people. + if (request.requestedBy && userId && request.requestedBy === userId) { + throw new ConflictException( + 'You filed this request — a different staff member must approve it', + ); + } + const pendingCheck = + request.action === WagonDetachRequestAction.Maintenance + ? await this.sendWagonToMaintenanceCore(manager, id, request.wagonId, userId, request.reason) + : await this.removeWagonCore(manager, id, request.wagonId, userId); + await repo.update(request.id, { + status: WagonDetachRequestStatus.Approved, + decidedBy: userId ?? null, + decidedAt: new Date(), + decisionNote, + }); + return pendingCheck; }); await this.reconcileWindowAfterConsistChange(pending); return this.getComposition(id); @@ -893,6 +1111,7 @@ export class TrainBuilderService { private async assertDetachableAndReleaseStaleSlots( manager: EntityManager, wagon: Wagon, + opts: { checkOnly?: boolean } = {}, ): Promise { const rows: { id: string; train_set_id: string; status: string; allocs: string }[] = await manager.query( @@ -915,6 +1134,7 @@ export class TrainBuilderService { `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`, ); } + if (opts.checkOnly) return; await manager.getRepository(TrainSetWagon).delete(rows.map((r) => r.id)); for (const trainSetId of [...new Set(rows.map((r) => r.train_set_id))]) { const remaining = await manager.getRepository(TrainSetWagon).find({ diff --git a/apps/edr-freight-api/src/modules/trains/trains.module.ts b/apps/edr-freight-api/src/modules/trains/trains.module.ts index 6009ebef7..1f50681cd 100644 --- a/apps/edr-freight-api/src/modules/trains/trains.module.ts +++ b/apps/edr-freight-api/src/modules/trains/trains.module.ts @@ -4,13 +4,17 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; import { TrainLocomotive } from './entities/train-locomotive.entity'; import { Train } from './entities/train.entity'; +import { WagonDetachRequest } from './entities/wagon-detach-request.entity'; import { TrainBuilderController } from './train-builder.controller'; import { TrainBuilderService } from './train-builder.service'; import { TrainsController } from './trains.controller'; import { TrainsService } from './trains.service'; @Module({ - imports: [TypeOrmModule.forFeature([Train, TrainLocomotive]), TrainSchedulingModule], + imports: [ + TypeOrmModule.forFeature([Train, TrainLocomotive, WagonDetachRequest]), + TrainSchedulingModule, + ], controllers: [TrainsController, TrainBuilderController], providers: [TrainsService, TrainBuilderService], exports: [TrainsService, TrainBuilderService], diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.controller.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.controller.ts index 977e677f8..3a94bb05a 100644 --- a/apps/edr-freight-api/src/modules/verifayda/verifayda.controller.ts +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.controller.ts @@ -17,7 +17,7 @@ import { } from '@nestjs/swagger'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; -import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { FreightJwtGuard } from '../../common/freight-jwt.guard'; import { OptionalJwtGuard } from './optional-jwt.guard'; import { CompleteVerificationResultDto, @@ -91,7 +91,7 @@ export class VerifaydaController { } @Get('status') - @UseGuards(JwtGuard) + @UseGuards(FreightJwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: "Get the current user's Fayda verification status", diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 558ffc1a9..ba34c61bf 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -20,8 +20,10 @@ import { Invoice } from "../billing/entities/invoice.entity"; import { InvoiceLine } from "../billing/entities/invoice-line.entity"; import { PayInvoiceDto as GatewayPayInvoiceDto } from "../billing/dto/pay-invoice.dto"; +import { settlementReferences } from "../billing/invoice-settlement.util"; import { InvoiceDocumentModel, + sameCompanyName, InvoiceDocumentService, } from "../billing/documents/invoice-document.service"; import { NotificationsService } from "../notifications/notifications.service"; @@ -71,6 +73,11 @@ const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [ export interface InvoiceDocumentDetails { bookingReference: string | null; customerName: string | null; + /** + * Trade name of the eTrade licence the billed company profile operates as. + * Null when nothing is attached, or for a company with no eTrade record. + */ + customerTradeName: string | null; inventoryReference: string | null; inventoryInfo: string | null; inventoryStatus: string | null; @@ -745,6 +752,17 @@ export class WarehouseInvoiceService { }, { label: "Booking reference", value: invoice.bookingReference ?? null }, { label: "Customer", value: invoice.customerName ?? null }, + // Which of the TIN's eTrade businesses was billed. Omitted when it just + // repeats the customer name — see sameCompanyName. + ...(invoice.customerTradeName && + !sameCompanyName(invoice.customerTradeName, invoice.customerName) + ? [ + { + label: "Customer trade name", + value: invoice.customerTradeName, + }, + ] + : []), { label: "Inventory reference", value: invoice.inventoryReference ?? null, @@ -768,6 +786,16 @@ export class WarehouseInvoiceService { ? `${lastPayment.method ?? "MANUAL"} / ${date(lastPayment.paidAt) ?? "-"}` : null, }, + // The provider's own transaction number (CBE `FT…`, telebirr receipt no., a + // teller's bank-slip ref) — the row above says only HOW and WHEN it was paid, + // which nobody can reconcile a bank statement against. The warehouse view + // projects the invoice ledger but not the linked gateway `payments` row, so the + // ledger is the only source here; it carries the provider ref on every path + // that has one. + { + label: "Transaction ref", + value: settlementReferences({ payments: invoice.payments }), + }, ], categoryHeader: "Fee type", lines: invoice.items.map((item) => ({ @@ -795,6 +823,7 @@ export class WarehouseInvoiceService { const [row] = await this.dataSource.query( `SELECT b.reference AS "bookingReference", company.name AS "customerName", + cp.etrade_business->>'tradeName' AS "customerTradeName", COALESCE(inv.release_order_reference, b.reference) AS "inventoryReference", inv.status AS "inventoryStatus", inv.release_date AS "releaseDate", @@ -812,6 +841,7 @@ export class WarehouseInvoiceService { FROM freight.warehouse_inventory inv LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.company_profiles cp ON cp.id = b.company_profile_id AND cp.deleted_at IS NULL LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL LEFT JOIN freight.booking_container booking_container ON ( booking_container.booking_id = b.id @@ -837,6 +867,7 @@ export class WarehouseInvoiceService { return { bookingReference: row?.bookingReference ?? null, customerName: row?.customerName ?? null, + customerTradeName: row?.customerTradeName ?? null, inventoryReference: row?.inventoryReference ?? null, inventoryInfo: row?.inventoryInfo ?? null, inventoryStatus: row?.inventoryStatus ?? null, diff --git a/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts b/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts index 6255c90fc..5ddc99cca 100644 --- a/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts +++ b/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts @@ -4,7 +4,7 @@ import type { } from "../../modules/contract-templates/entities/contract-template.entity"; /** - * Default article packs for the ten contract templates, transcribed from the + * Default article packs for the fourteen contract templates, transcribed from the * signed EDR contract documents (test/contrat_docs). Article bodies use the * dynamic-article text format: one clause per line, "- " prefix for bullets * nested under the previous clause, single-line body = plain paragraph. @@ -23,7 +23,8 @@ export interface ContractTemplateSeed { /** * A base pack keyed by direction/freight only. Each one is transcribed from a * signed EDR contract and is split at the bottom of this file into the - * `_CUSTOMS` / `_NO_CUSTOMS` pair the template table actually stores. + * `_CUSTOMS` / `_ETHIOPIAN_CUSTOMS` / `_NO_CUSTOMS` trio the template table + * actually stores. */ type ContractTemplateBase = Omit; @@ -883,7 +884,35 @@ Settle assessed duties and taxes within the period notified by the Service Provi ), ]; -/** Build the stored `_CUSTOMS` / `_NO_CUSTOMS` pair for one base pack. */ +/** + * Articles appended to the `_ETHIOPIAN_CUSTOMS` variant: the Service Provider + * clears the Ethiopian side only, while Djibouti clearing stays with the + * Client. Article ids match the full-customs pack so downstream checks treat + * both as customs-clearing variants. + */ +const ETHIOPIAN_CUSTOMS_ARTICLES: Array> = [ + a( + "customs-clearing", + "Ethiopian Customs Clearing Services", + `The Service Provider shall carry out customs clearing on behalf of the Client for the cargo covered by this Agreement at the customs stations of Ethiopia only, including declaration, lodgement, and follow-up as applicable to the agreed corridor. +Customs clearing at Djibouti is not included in this Agreement and remains the sole responsibility of the Client. +The Service Provider shall act only within the authority granted by the Client and shall not amend a declaration without the Client's written instruction. +Customs duties, taxes, and any government charges assessed on the cargo remain payable by the Client and are not included in the freight price; the Service Provider shall settle them on the Client's behalf only where the Client has placed the corresponding funds in advance. +The Service Provider shall hand over all customs documents obtained in the course of clearing to the Client upon completion of each shipment.`, + ), + a( + "customs-client-duties", + "Client Obligations for Ethiopian Customs Clearing", + `Grant the Service Provider a duly signed and stamped power of attorney authorising it to act as the Client's customs agent in Ethiopia for the duration of this Agreement. +Complete customs clearing at Djibouti and deliver the cargo customs-cleared on the Djibouti side, together with the supporting release documents, in time for the scheduled railway loading. +Submit every document required for declaration (commercial invoice, packing list, bill of lading or airway bill, permits, certificates of origin, and any authority-specific licence) within one (1) calendar day of the Service Provider's request. +Warrant that the declared description, quantity, value, and tariff classification of the cargo are complete and accurate. +Bear any penalty, demurrage, storage, or re-inspection cost arising from incorrect, incomplete, or late Client-supplied information or documentation, or from delayed Djibouti-side clearing. +Settle assessed duties and taxes within the period notified by the Service Provider, failing which the Service Provider may suspend clearing and the cargo shall remain at the Client's risk and cost.`, + ), +]; + +/** Build the stored `_CUSTOMS` / `_ETHIOPIAN_CUSTOMS` / `_NO_CUSTOMS` trio for one base pack. */ function splitByCustoms( base: ContractTemplateBase, codeStem: string, @@ -896,6 +925,14 @@ function splitByCustoms( description: `${base.description} Customs clearing is performed by the Service Provider.`, articles: [...base.articles, ...CUSTOMS_ARTICLES], }, + { + ...base, + code: `${codeStem}_ETHIOPIAN_CUSTOMS` as ContractTemplateCode, + name: `${base.name} (Ethiopian customs clearing only)`, + description: `${base.description} Only Ethiopian customs clearing is performed by the Service Provider; Djibouti clearing is handled by the Client.`, + documentTitle: `${base.documentTitle} (Ethiopian Customs Clearing Only)`, + articles: [...base.articles, ...ETHIOPIAN_CUSTOMS_ARTICLES], + }, { ...base, code: `${codeStem}_NO_CUSTOMS` as ContractTemplateCode, @@ -907,7 +944,8 @@ function splitByCustoms( } /** - * Ten templates: import and export each split by customs clearing, intercity + * Fourteen templates: import and export each split by customs clearing option + * (full, Ethiopian-only, none), intercity * not split at all — it is a domestic Ethiopian movement that crosses no * border, so there is no customs leg to contract for. */ diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index d0d437fb1..ac2507aa5 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1014,6 +1014,14 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:trains:change_wagon_yard", "Change yard of a coupled wagon", ), + // Supervisor-only: NOT part of FLEET_GRANULAR_KEYS — detach requests are + // filed under trains:assign_wagons, but deciding them is a separate grant so + // the requester and approver are different people. + perm( + "e1c00001-0001-4000-8000-000000000011", + "edr_freight_app:trains:approve_wagon_detach", + "Approve wagon detach/maintenance requests", + ), perm( "e1d00001-0001-4000-8000-000000000001", "edr_freight_app:routes:view", @@ -1485,6 +1493,30 @@ export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:train_scheduling:unload", "Confirm cargo unloaded (import, export, intercity)", ), + // Per-station loading/unloading time windows: the four buttons are separate + // permissions so start and end can be granted to different people. Booking + // load/unload additionally requires the matching window to have been started + // at that yard. + perm( + "a2a00001-0001-4000-8000-000000000008", + "edr_freight_app:train_scheduling:loading_start", + "Start a station's loading window", + ), + perm( + "a2a00001-0001-4000-8000-000000000009", + "edr_freight_app:train_scheduling:loading_end", + "End a station's loading window", + ), + perm( + "a2a00001-0001-4000-8000-000000000010", + "edr_freight_app:train_scheduling:unloading_start", + "Start a station's unloading window", + ), + perm( + "a2a00001-0001-4000-8000-000000000011", + "edr_freight_app:train_scheduling:unloading_end", + "End a station's unloading window", + ), ]; // L. Administration & settings (split from the coarse admin umbrella) @@ -2021,6 +2053,11 @@ export const FREIGHT_PERMS = { */ load: "edr_freight_app:train_scheduling:load", unload: "edr_freight_app:train_scheduling:unload", + // Per-station loading/unloading time-window buttons (start/end pairs). + loadingStart: "edr_freight_app:train_scheduling:loading_start", + loadingEnd: "edr_freight_app:train_scheduling:loading_end", + unloadingStart: "edr_freight_app:train_scheduling:unloading_start", + unloadingEnd: "edr_freight_app:train_scheduling:unloading_end", dispatch: "edr_freight_app:train_scheduling:dispatch", markPaid: "edr_freight_app:train_scheduling:mark_paid", expireBooking: "edr_freight_app:train_scheduling:expire_booking", @@ -2187,6 +2224,8 @@ export const FREIGHT_PERMS = { changeWagonYard: "edr_freight_app:trains:change_wagon_yard", toggleActive: "edr_freight_app:trains:toggle_active", disband: "edr_freight_app:trains:disband", + /** Decide detach/maintenance requests on a SCHEDULED train (4-eyes gate). */ + approveWagonDetach: "edr_freight_app:trains:approve_wagon_detach", }, routes: { view: "edr_freight_app:routes:view", @@ -2629,6 +2668,10 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.trainScheduling.update, FREIGHT_PERMS.trainScheduling.load, FREIGHT_PERMS.trainScheduling.unload, + FREIGHT_PERMS.trainScheduling.loadingStart, + FREIGHT_PERMS.trainScheduling.loadingEnd, + FREIGHT_PERMS.trainScheduling.unloadingStart, + FREIGHT_PERMS.trainScheduling.unloadingEnd, FREIGHT_PERMS.trainScheduling.cancel, FREIGHT_PERMS.trainScheduling.reschedule, FREIGHT_PERMS.trainScheduling.rulesManage, @@ -2851,6 +2894,10 @@ export const POSITION_PERMISSION_PRESETS = { FREIGHT_PERMS.trainScheduling.update, FREIGHT_PERMS.trainScheduling.load, FREIGHT_PERMS.trainScheduling.unload, + FREIGHT_PERMS.trainScheduling.loadingStart, + FREIGHT_PERMS.trainScheduling.loadingEnd, + FREIGHT_PERMS.trainScheduling.unloadingStart, + FREIGHT_PERMS.trainScheduling.unloadingEnd, FREIGHT_PERMS.trainScheduling.cancel, FREIGHT_PERMS.trainScheduling.reschedule, FREIGHT_PERMS.trainScheduling.rulesManage, diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx index 67851ab30..641541634 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx @@ -128,14 +128,27 @@ export function BookingRouteServiceCard({ background: "#F8FAFC", }} > - - - - Customs clearing agent:{" "} - - {booking.customsClearingAgent} + + + + + Customs clearing agent:{" "} + + {booking.customsClearingAgent} + - + {(booking.customsClearingAgentEmail || + booking.customsClearingAgentPhone) && ( + + {[ + booking.customsClearingAgentEmail, + booking.customsClearingAgentPhone, + ] + .filter(Boolean) + .join(" · ")} + + )} + ) : null} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/DoCollectionDateFields.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/DoCollectionDateFields.tsx index 365b8e404..ecc2a6104 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/DoCollectionDateFields.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/DoCollectionDateFields.tsx @@ -36,6 +36,13 @@ export function DoCollectionDateFields({ const outOfOrder = Boolean(value.vesselArrival && value.doCollected) && !doDatesComplete(value); + const today = new Date(); + today.setHours(0, 0, 0, 0); + const doMin = + value.vesselArrival && value.vesselArrival > today + ? value.vesselArrival + : today; + return ( onChange({ ...value, vesselArrival: v ? new Date(v) : null }) } - maxDate={new Date()} + minDate={today} size="sm" required withAsterisk @@ -57,8 +64,7 @@ export function DoCollectionDateFields({ onChange={(v) => onChange({ ...value, doCollected: v ? new Date(v) : null }) } - minDate={value.vesselArrival ?? undefined} - maxDate={new Date()} + minDate={doMin} size="sm" required withAsterisk diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index e208328cc..2e31d2a8c 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -88,6 +88,7 @@ import { import { ConsolidationPartnerPanel, emptyPartnerLine, + emptyPartnerUnit, } from "./gl-booking-form/ConsolidationPartnerPanel"; import { ConsolidationPartnerPicker } from "./gl-booking-form/ConsolidationPartnerPicker"; @@ -129,6 +130,7 @@ interface LineErrors { interface BulkErrors { quantity?: string; + wagons?: string; hazardous?: string; reefer?: string; } @@ -174,6 +176,8 @@ interface ContainerLineDraft { interface BulkDraft { cargoWeightTons: string; itemCount: string; + /** NUMBER_OF_WAGONS cargo only: wagons this shipment needs. */ + requestedWagons: string; hazardousQuantity: string; reeferQuantity: string; } @@ -202,7 +206,19 @@ function emptyLine(size: string): ContainerLineDraft { function bulkUnitOfMeasure( contract: Freight.IContract, -): "PER_TON" | "PER_ITEM" { +): "PER_TON" | "PER_ITEM" | "NUMBER_OF_WAGONS" { + // The cargo type's own configured unit wins; the pricing-line sniff below is + // the legacy fallback for contracts loaded without the cargoScope relation. + const configured = contract.cargoScope?.find( + (scope) => scope.cargoType?.unitOfMeasure, + )?.cargoType?.unitOfMeasure; + if ( + configured === "PER_TON" || + configured === "PER_ITEM" || + configured === "NUMBER_OF_WAGONS" + ) { + return configured; + } const hasPerItem = contract.pricingBreakdown?.lineItems?.some( (li) => li.unit === "per_item", ); @@ -250,6 +266,17 @@ export default function GlCreateBookingForm() { enabled: Boolean(requestId), }); + // The shipment request is the customer's order: container sizes/quantities + // and the billing currency are the customer's choices and stay read-only — + // GL enters only per-unit details (numbers, seals, VGM, handling). The + // server enforces the same on completion. + const requestContainersLocked = Boolean( + bookingRequest?.requestedLines?.containers?.length, + ); + const requestBulkLocked = + bookingRequest?.requestedLines?.bulk?.cargoWeightTons != null; + const requestCurrencyLocked = Boolean(bookingRequest?.paymentCurrency); + // The expired booking a Rebook is copying from (its cargo seeds the form). const { data: copyFromBooking } = useQuery({ queryKey: ["rebook-copy-from", copyFromParam], @@ -310,6 +337,7 @@ export default function GlCreateBookingForm() { const [bulk, setBulk] = useState({ cargoWeightTons: "", itemCount: "", + requestedWagons: "", hazardousQuantity: "0", reeferQuantity: "0", }); @@ -328,6 +356,39 @@ export default function GlCreateBookingForm() { const [partner, setPartner] = useState(null); const [partnerLines, setPartnerLines] = useState([]); const [partnerCargoDescription, setPartnerCargoDescription] = useState(""); + + // The partner is its own customer: if a shipment request created it, that + // request locks the partner's quantities and billing currency the same way + // this booking's request locks this side (server enforces both halves). + const { data: partnerContractRequests } = useQuery({ + queryKey: ["shipment-requests-for-contract", partner?.contractId], + queryFn: () => contractsService.listBookingRequests(partner!.contractId!), + enabled: Boolean(partner?.contractId), + }); + const partnerRequest = + (partner && + partnerContractRequests?.find( + (r) => r.createdBookingId === partner.id, + )) || + null; + const partnerLocked = Boolean(partnerRequest?.requestedLines?.containers?.length); + + // Seed (and lock) the partner's lines from its request once it loads. + useEffect(() => { + const requested = partnerRequest?.requestedLines?.containers; + if (!partner || !requested?.length) return; + setPartnerLines( + requested.map((c) => ({ + containerSize: c.containerSize, + quantity: String(Math.max(1, c.quantity)), + hazardousQuantity: "0", + reeferQuantity: "0", + returnQuantity: "0", + units: Array.from({ length: Math.max(1, c.quantity) }, emptyPartnerUnit), + })), + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [partner?.id, partnerRequest?.id]); const seededRef = useRef(false); const returnSeededRef = useRef(false); @@ -476,20 +537,30 @@ export default function GlCreateBookingForm() { })), ); } else if (lines.bulk) { - setBulk({ + setBulk((b) => ({ cargoWeightTons: - lines.bulk.cargoWeightTons != null - ? String(lines.bulk.cargoWeightTons) + lines.bulk!.cargoWeightTons != null + ? String(lines.bulk!.cargoWeightTons) : "", itemCount: - lines.bulk.itemCount != null ? String(lines.bulk.itemCount) : "", - hazardousQuantity: String(lines.bulk.hazardousQuantity ?? 0), + lines.bulk!.itemCount != null ? String(lines.bulk!.itemCount) : "", + // The request never carries a wagon count — GL enters it here. + requestedWagons: b.requestedWagons, + hazardousQuantity: String(lines.bulk!.hazardousQuantity ?? 0), reeferQuantity: "0", - }); + })); } if (bookingRequest.contractRouteId) setContractRouteId(bookingRequest.contractRouteId); if (bookingRequest.notes) setNotes(bookingRequest.notes); + // Currency is the customer's choice on the request — seed it here; the + // selector below is disabled while the request specifies one. + if ( + bookingRequest.paymentCurrency === "USD" || + bookingRequest.paymentCurrency === "ETB" + ) { + setPaymentCurrency(bookingRequest.paymentCurrency); + } }, [bookingRequest, prefilled]); // Rebook seed: copy the source booking's container lines once. (Bulk weight / @@ -565,6 +636,7 @@ export default function GlCreateBookingForm() { returnQuantity: Number(l.returnQuantity || 0), })), bulkQuantity: Number(bulk.cargoWeightTons || bulk.itemCount || 0), + bulkRequestedWagons: Number(bulk.requestedWagons || 0), bulkHazardousQuantity: Number(bulk.hazardousQuantity || 0), bulkReeferQuantity: Number(bulk.reeferQuantity || 0), }), @@ -926,6 +998,12 @@ export default function GlCreateBookingForm() { if (Number.isNaN(qty) || qty <= 0) { errs.quantity = "Enter a quantity greater than 0."; } + if (bulkUom === "NUMBER_OF_WAGONS") { + const wagons = Number(bulk.requestedWagons || 0); + if (!Number.isInteger(wagons) || wagons < 1) { + errs.wagons = "Enter the number of wagons needed (at least 1)."; + } + } const h = Number(bulk.hazardousQuantity || 0); if (Number.isNaN(h) || h < 0) { errs.hazardous = "Enter a valid hazardous quantity."; @@ -964,7 +1042,10 @@ export default function GlCreateBookingForm() { line.every((e) => !e.containerNumber && !e.vgmTons), ) && !cargoDescriptionError - : !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer; + : !bulkErrors.quantity && + !bulkErrors.wagons && + !bulkErrors.hazardous && + !bulkErrors.reefer; // COMPLETION never blocks on an odd 20ft total: a customs instance can share // the wagon via the manual pair (consolidationActive), and anything else is @@ -1099,6 +1180,9 @@ export default function GlCreateBookingForm() { reeferQuantity: Number(bulk.reeferQuantity || 0) || undefined, }, ]; + if (bulkUom === "NUMBER_OF_WAGONS" && bulk.requestedWagons !== "") { + payload.requestedWagons = Number(bulk.requestedWagons); + } } return payload; @@ -1114,7 +1198,13 @@ export default function GlCreateBookingForm() { if (!partner || !consolidationActive) return null; const payload: Freight.CreateBookingUnderContractDto = { - paymentCurrency: effectiveCurrency, + // The partner's customer chose its own currency on its shipment request; + // only a partner without a request falls back to this booking's currency. + paymentCurrency: + partnerRequest?.paymentCurrency === "USD" || + partnerRequest?.paymentCurrency === "ETB" + ? partnerRequest.paymentCurrency + : effectiveCurrency, ...(scheduledDate ? { scheduledDate: new Date(scheduledDate).toISOString() } : {}), @@ -1663,6 +1753,12 @@ export default function GlCreateBookingForm() { label="Quantity *" min={0} value={line.quantity} + disabled={requestContainersLocked} + description={ + requestContainersLocked + ? "Requested by the customer — quantity cannot be changed." + : undefined + } error={ showErrors ? (lineErrors[lineIdx]?.quantity ?? @@ -1924,6 +2020,7 @@ export default function GlCreateBookingForm() { showReefer={Boolean(contract.isReefer)} showErrors={showErrors} error={partnerError} + lockQuantities={partnerLocked} /> ) : null} @@ -1946,15 +2043,22 @@ export default function GlCreateBookingForm() { placeholder="e.g. 1200" min={0} step={0.01} + disabled={requestBulkLocked} + description={ + requestBulkLocked + ? "Requested by the customer — quantity cannot be changed." + : undefined + } value={bulk.cargoWeightTons} error={ showErrors && bulkUom === "PER_TON" ? bulkErrors.quantity : undefined } - onChange={(e) => - setBulk((b) => ({ ...b, cargoWeightTons: e.currentTarget.value })) - } + onChange={(e) => { + const value = e.currentTarget.value; + setBulk((b) => ({ ...b, cargoWeightTons: value })); + }} radius={10} styles={fieldStyles} /> @@ -1971,12 +2075,32 @@ export default function GlCreateBookingForm() { ? bulkErrors.quantity : undefined } - onChange={(e) => - setBulk((b) => ({ ...b, itemCount: e.currentTarget.value })) - } + onChange={(e) => { + const value = e.currentTarget.value; + setBulk((b) => ({ ...b, itemCount: value })); + }} radius={10} styles={fieldStyles} /> + {bulkUom === "NUMBER_OF_WAGONS" && ( + { + const value = e.currentTarget.value; + setBulk((b) => ({ ...b, requestedWagons: value })); + }} + radius={10} + styles={fieldStyles} + /> + )} {contract.isHazardous && ( - setBulk((b) => ({ - ...b, - hazardousQuantity: e.currentTarget.value, - })) - } + onChange={(e) => { + const value = e.currentTarget.value; + setBulk((b) => ({ ...b, hazardousQuantity: value })); + }} radius={10} styles={fieldStyles} /> @@ -2005,12 +2127,10 @@ export default function GlCreateBookingForm() { step={1} value={bulk.reeferQuantity} error={showErrors ? bulkErrors.reefer : undefined} - onChange={(e) => - setBulk((b) => ({ - ...b, - reeferQuantity: e.currentTarget.value, - })) - } + onChange={(e) => { + const value = e.currentTarget.value; + setBulk((b) => ({ ...b, reeferQuantity: value })); + }} radius={10} styles={fieldStyles} /> @@ -2147,14 +2267,16 @@ export default function GlCreateBookingForm() { Billing currency - {isImport - ? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online." - : "Shipments are invoiced in ETB."} + {requestCurrencyLocked + ? "The customer chose the billing currency on the shipment request — it cannot be changed." + : isImport + ? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online." + : "Shipments are invoiced in ETB."} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx index e52cce097..a3be22ea1 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx @@ -86,6 +86,11 @@ interface Props { /** Surface field errors only after the operator tried to continue. */ showErrors: boolean; error?: string; + /** + * The partner's shipment request fixed its sizes/quantities — the quantity + * fields render read-only and GL enters only per-unit details. + */ + lockQuantities?: boolean; } export function ConsolidationPartnerPanel({ @@ -97,6 +102,7 @@ export function ConsolidationPartnerPanel({ showReefer, showErrors, error, + lockQuantities, }: Props) { const patchLine = (index: number, patch: Partial) => { onLinesChange( @@ -149,6 +155,12 @@ export function ConsolidationPartnerPanel({ label="Quantity *" min={0} value={line.quantity} + disabled={lockQuantities} + description={ + lockQuantities + ? "Requested by the partner's customer — quantity cannot be changed." + : undefined + } onChange={(e) => patchLine(lineIdx, { quantity: e.currentTarget.value })} // Sync off the typed value, not the captured `line` — that snapshot // still holds the pre-edit quantity and would write it back. diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts index 9131196c7..3d8448bfa 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts @@ -28,6 +28,8 @@ export interface GlShipmentQuantities { }>; /** Bulk: tons (or item count) + hazardous/reefer qty. */ bulkQuantity: number; + /** NUMBER_OF_WAGONS cargo: the wagon count GL enters (0 otherwise). */ + bulkRequestedWagons: number; bulkHazardousQuantity: number; bulkReeferQuantity: number; } @@ -132,7 +134,6 @@ export function computeGlShipmentTotal( } } } else { - const qty = q.bulkQuantity; const rate = rateFor( (i) => @@ -140,6 +141,9 @@ export function computeGlShipmentTotal( !i.isClearance && !i.conditionalOn, ) ?? items[0]; + // NUMBER_OF_WAGONS cargo: a per-wagon base rate bills the requested count. + const qty = + rate?.unit === "per_wagon" ? q.bulkRequestedWagons : q.bulkQuantity; if (rate && qty > 0) { lines.push({ label: rate.label, @@ -179,8 +183,14 @@ export function computeGlShipmentTotal( // it (the commodity needs lashing). Per-ton scales by tonnage; per-wagon // depends on the wagon capacity the train stocks — shown at real pricing. const lashing = items.find((i) => i.conditionalOn === "has_lashing"); - if (lashing && (lashing.unit === "per_ton" || lashing.unit === "per_item")) { - const tons = q.bulkQuantity; + if ( + lashing && + (lashing.unit === "per_ton" || + lashing.unit === "per_item" || + (lashing.unit === "per_wagon" && q.bulkRequestedWagons > 0)) + ) { + const tons = + lashing.unit === "per_wagon" ? q.bulkRequestedWagons : q.bulkQuantity; if (tons > 0) { lines.push({ label: lashing.label, @@ -208,6 +218,8 @@ export function computeGlShipmentTotal( : boxes; } else if (cl.unit === "per_ton" || cl.unit === "per_item") { qty = q.bulkQuantity; + } else if (cl.unit === "per_wagon") { + qty = q.bulkRequestedWagons; } else if (cl.unit === "flat") { qty = 1; } diff --git a/apps/edr-freight-web/backoffice/src/components/customers/AccountCard.tsx b/apps/edr-freight-web/backoffice/src/components/customers/AccountCard.tsx new file mode 100644 index 000000000..438f2293a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/customers/AccountCard.tsx @@ -0,0 +1,188 @@ +import { + Avatar, + Badge, + Box, + Card, + Divider, + Group, + Stack, + Text, +} from "@mantine/core"; +import { AtSign, Phone, ShieldAlert, UserRound } from "lucide-react"; + +import type { CustomerAccount } from "@/types/customer"; +import { formatDate, humanize } from "./format"; + +/** First letters of the person's name; falls back to the login initial. */ +function initials(account: CustomerAccount): string { + const letters = [account.firstName, account.lastName] + .map((n) => n?.trim()?.[0]) + .filter(Boolean) + .join(""); + return (letters || account.username?.[0] || "?").toUpperCase(); +} + +/** A labelled value; rendered only when there is something to show. */ +function Field({ + icon, + label, + value, + after, +}: { + icon: React.ReactNode; + label: string; + value?: string | null; + after?: React.ReactNode; +}) { + if (!value?.trim()) return null; + return ( + + + {icon} + + + + {label} + + + + {value} + + {after} + + + + ); +} + +/** + * One portal login belonging to a customer. + * + * Distinct from the contact details on the Overview tab: those are the business + * contact info on the company row, this is the credential someone actually + * signs in with — the two drift apart routinely, and staff answering "the + * customer can't log in" need this one. + */ +export function AccountCard({ account }: { account: CustomerAccount }) { + const name = + `${account.firstName ?? ""} ${account.lastName ?? ""}`.trim() || + account.username || + "Unnamed account"; + + // No IAM row at all — the profile points at a user that is gone. Treated as a + // fault rather than a status: nothing below it can be trusted, so the card + // says so once, loudly, instead of drawing empty credential fields. + const orphaned = account.username === null; + + return ( + + + + + {initials(account)} + + + + + {name} + + {account.isPrimaryContact && ( + + Primary contact + + )} + + {account.jobTitle && ( + + {account.jobTitle} + + )} + + + + + {orphaned ? ( + } + > + No IAM account + + ) : ( + <> + + {account.isActive ? "Active" : "Inactive"} + + {account.status && ( + + {humanize(account.status)} + + )} + {/* Created but never activated by its owner — usually the actual + answer to "they say they never got in". */} + {account.hasSetPassword === false && ( + + Password never set + + )} + + )} + {account.onboardingCompleted ? ( + + Onboarding submitted + + ) : ( + + Onboarding + {account.onboardingStep + ? ` · ${humanize(account.onboardingStep)}` + : " in progress"} + + )} + + + {!orphaned && ( + <> + + + } + label="Username" + value={account.username} + /> + } + label="Email" + value={account.email} + /> + } + label="Phone" + value={account.phoneNumber} + after={ + account.phoneVerified === false ? ( + + Unverified + + ) : undefined + } + /> + + + )} + + + Created {formatDate(account.createdAt)} + + + + ); +} + +export default AccountCard; diff --git a/apps/edr-freight-web/backoffice/src/components/customers/TableCard.tsx b/apps/edr-freight-web/backoffice/src/components/customers/TableCard.tsx index b67181da5..14aa8e80e 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/TableCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/TableCard.tsx @@ -3,6 +3,12 @@ import type { ReactNode } from "react"; export interface TableCardProps { children: ReactNode; + /** + * Optional heading row (title, chips, actions). Rendered in its own padded + * section above the table and OUTSIDE the scroll region — a header inside it + * would slide away from its own table on a narrow viewport. + */ + header?: ReactNode; /** * Minimum width (px) the table is forced to occupy. The Mantine `Table` is * always `width: 100%`, so without a floor it can never overflow its @@ -14,14 +20,27 @@ export interface TableCardProps { } /** - * Flush card shell for a `DataTable`: a borderless, padding-less card whose - * single child is a horizontally scrollable region. Pair with the table's - * `containerClassName="border-0 shadow-none bg-transparent"` so every table on - * the customer pages reads identically (same surface, same scroll behaviour). + * Flush card shell for a `DataTable`: a padding-less card whose table region + * runs edge to edge. Padding is applied per section rather than to the card, so + * the optional {@link TableCardProps.header} is inset like any other card + * content while the table's own rows and header cells reach both edges. + * + * Pair with the table's `containerClassName="border-0 shadow-none bg-transparent"` + * so every table on the customer pages reads identically (same surface, same + * scroll behaviour). */ -export function TableCard({ children, minWidth = 860 }: TableCardProps) { +export function TableCard({ + children, + minWidth = 860, + header, +}: TableCardProps) { return ( + {header && ( + + {header} + + )} {children} diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts index 864a89ae6..463a85eca 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts @@ -15,6 +15,7 @@ export { ChangeRequestReview, ChangeRequestPendingBadge, } from "./ChangeRequestReview"; +export { AccountCard } from "./AccountCard"; export { CompanyTimeline } from "./CompanyTimeline"; export { RequestDocumentChangeModal, diff --git a/apps/edr-freight-web/backoffice/src/components/reports/ReportExportButton.tsx b/apps/edr-freight-web/backoffice/src/components/reports/ReportExportButton.tsx index 76ff7d628..89cd638a6 100644 --- a/apps/edr-freight-web/backoffice/src/components/reports/ReportExportButton.tsx +++ b/apps/edr-freight-web/backoffice/src/components/reports/ReportExportButton.tsx @@ -1,14 +1,16 @@ import { Button, Checkbox, Group, Modal, Radio, Select, SimpleGrid, Stack, Text } from "@mantine/core"; import { Download, FileSpreadsheet, FileText } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { reportsService } from "@/services/reports.service"; -import type { ReportCatalogEntry, ReportRunParams } from "@/types/reports"; +import type { ReportCatalogEntry, ReportColumn, ReportRunParams } from "@/types/reports"; interface ReportExportButtonProps { def: ReportCatalogEntry; /** Filters + sort currently applied on screen — no key/page/pageSize. */ params: Omit; + /** The columns on screen, which for a report with `hideWhen` is not all of them. */ + columns: ReportColumn[]; } const RECORD_OPTIONS = [ @@ -31,17 +33,21 @@ function saveBlob(blob: Blob, filename: string) { /** One export button: format, which fields, how many records — applies the * filters/sort already on screen. Record count defaults to all (capped * server-side per format). */ -export function ReportExportButton({ def, params }: ReportExportButtonProps) { +export function ReportExportButton({ def, params, columns }: ReportExportButtonProps) { const [opened, setOpened] = useState(false); const [format, setFormat] = useState<"xlsx" | "pdf">("xlsx"); - const [fields, setFields] = useState(def.columns.map((c) => c.key)); + const [fields, setFields] = useState(columns.map((c) => c.key)); const [records, setRecords] = useState("all"); const [exporting, setExporting] = useState(false); - const allSelected = fields.length === def.columns.length; + // A filter change can change which columns exist at all — start over from the + // new set rather than exporting keys the query no longer selects. + useEffect(() => setFields(columns.map((c) => c.key)), [columns]); + + const allSelected = fields.length === columns.length; const toggleField = (key: string) => setFields((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key])); - const toggleAll = () => setFields(allSelected ? [] : def.columns.map((c) => c.key)); + const toggleAll = () => setFields(allSelected ? [] : columns.map((c) => c.key)); const handleDownload = async () => { setExporting(true); @@ -110,7 +116,7 @@ export function ReportExportButton({ def, params }: ReportExportButtonProps) { - {def.columns.map((col) => ( + {columns.map((col) => ( { + const applied = appliedParams as Record; + const valueOf = (key: string) => applied[key] ?? def?.filters.find((f) => f.key === key)?.defaultValue; + return (def?.columns ?? []).filter((col) => + Object.entries(col.hideWhen ?? {}).every(([key, value]) => valueOf(key) !== value), + ); + }, [def?.columns, def?.filters, appliedParams]); + + /** A chart whose x or y column is hidden has nothing to plot — drop the toggle. */ + const chartDef = useMemo(() => { + if (!def?.chart) return undefined; + const shown = new Set(visibleColumns.map((c) => c.key)); + return shown.has(def.chart.x) && def.chart.y.every((k) => shown.has(k)) ? def.chart : undefined; + }, [def?.chart, visibleColumns]); + const columns: ColumnDef>[] = useMemo( () => - (def?.columns ?? []).map((col) => ({ + visibleColumns.map((col) => ({ id: col.key, accessorKey: col.key, header: col.sortable @@ -188,7 +208,7 @@ export function ReportView({ reportKey, idKeyValue, pageHeader, defaultView }: R ), })), - [def?.columns], + [visibleColumns], ); if (!def) { @@ -197,7 +217,7 @@ export function ReportView({ reportKey, idKeyValue, pageHeader, defaultView }: R ) : null; } - const chartToggle = def.chart ? ( + const chartToggle = chartDef ? ( ); - const exportButton = ; + const exportButton = ; return ( @@ -269,8 +289,8 @@ export function ReportView({ reportKey, idKeyValue, pageHeader, defaultView }: R - {view === "chart" && def.chart ? ( - + {view === "chart" && chartDef ? ( + ) : ( { + if (isAxiosError(error)) { + const message = error.response?.data?.message; + if (Array.isArray(message)) return message.join(", "); + if (typeof message === "string") return message; + } + return fallback; +}; + interface Props { trainId: string; /** Staff may attach and the train is editable (not out on a run). */ @@ -50,6 +62,47 @@ export default function DetachedWagonsPanel({ // Selection is page-scoped in the header checkbox but survives paging, so // staff can gather wagons across pages into one attach. const [selected, setSelected] = useState>(new Set()); + + // Attach the selection to a DIFFERENT built train: pick a target, reuse the + // same assign endpoint with that train's id. The builder attach is + // yard-agnostic, so any loose AVAILABLE wagon qualifies; a train that is + // out on a run rejects server-side and is disabled here too. + const { toast } = useToast(); + const [targetTrainId, setTargetTrainId] = useState(null); + const trainsQuery = useQuery( + api.trainBuilder.list.queryOptions({ + input: { filters: { pageSize: 200, sortBy: "code", sortOrder: "ASC" } }, + enabled: canAttach, + staleTime: 60_000, + }), + ); + const trainOptions = (trainsQuery.data?.items ?? []) + .filter((t) => t.id !== trainId) + .map((t) => ({ + value: t.id, + label: `${t.code}${t.trainName ? ` · ${t.trainName}` : ""} — ${t.wagonCount} wagon${t.wagonCount === 1 ? "" : "s"}${t.status === "IN_SERVICE" ? " (in service)" : ""}`, + disabled: t.status === "IN_SERVICE", + })); + const attachOther = useMutation(api.trainBuilder.assignWagons.mutationOptions()); + const handleAttachOther = async () => { + if (!targetTrainId || !selected.size) return; + const target = trainsQuery.data?.items.find((t) => t.id === targetTrainId); + try { + await attachOther.mutateAsync({ id: targetTrainId, wagonIds: [...selected] }); + toast({ + title: `${selected.size} wagon(s) attached to ${target?.code ?? "the selected train"}`, + }); + setSelected(new Set()); + setTargetTrainId(null); + void query.refetch(); + } catch (error) { + toast({ + title: "Could not attach to the other train", + description: parseError(error, "The target train may be out on a run."), + variant: "destructive", + }); + } + }; const allSelected = rows.length > 0 && rows.every((r) => selected.has(r.wagonId)); const toggle = (wagonId: string, checked: boolean) => @@ -79,17 +132,40 @@ export default function DetachedWagonsPanel({ {canAttach ? ( - + + +
${esc(wagon.wagonType)} ${wagon.tareWeightTons == null ? '-' : esc(Number(wagon.tareWeightTons).toFixed(2))} ${wagon.equatedLengthM == null ? '-' : esc(Number(wagon.equatedLengthM).toFixed(3))}${esc(loadList.origin)}${esc(loadList.destination)}${esc(wagon.boardYard ?? loadList.origin)}${esc(wagon.alightYard ?? loadList.destination)}${esc(allocation.loadType)} ${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')} ${esc(sealNumbers || '-')}${wagon.boardYard ? `TO BE LOADED AT ${esc(wagon.boardYard).toUpperCase()}` : ''} ${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}
+ + + Station + Event + Time + Handling + Note + + + + + {checkpoints.map((cp) => { + const hours = handlingHours(cp); + const tone = KIND_TONE[cp.kind] ?? KIND_TONE.PASSED; + return ( + + + + + + + + {cp.label ?? `Station ${cp.sequenceNo}`} + + + + + + {cp.kind} + + + + + {fmt(cp.occurredAt)} + + + + + {hours === null ? "—" : `${hours} h`} + + + + + {cp.note || "—"} + + + + {onEdit ? ( + + + + ) : null} + + + ); + })} + +
+ + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/CheckpointTimeModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/CheckpointTimeModal.tsx index c024cb0e9..e6df245b0 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/CheckpointTimeModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/CheckpointTimeModal.tsx @@ -1,4 +1,4 @@ -import { Button, Divider, Group, Modal, SimpleGrid, Stack, Text, Textarea } from "@mantine/core"; +import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core"; import { DateTimePicker } from "@mantine/dates"; import { useMediaQuery } from "@mantine/hooks"; import { useEffect, useState } from "react"; @@ -67,6 +67,10 @@ export function CheckpointTimeModal({ const isSmallScreen = useMediaQuery("(max-width: 48em)"); const [at, setAt] = useState(null); const [note, setNote] = useState(""); + // The four station-work stamps are no longer edited HERE — the track page's + // "Loading & unloading windows" section owns start/end with its own + // permissions. The modal still carries any existing stamps through + // unchanged on submit, so editing a checkpoint never wipes them. const [handling, setHandling] = useState(EMPTY_HANDLING); useEffect(() => { if (!opened) return; @@ -94,7 +98,7 @@ export function CheckpointTimeModal({ onClose={onClose} centered fullScreen={isSmallScreen} - radius="lg" + radius={18} title={ {icon} @@ -121,34 +125,6 @@ export function CheckpointTimeModal({ radius="md" /> - - - Loading and unloading times for this stop. Total handling is unloading start to - loading finish; the rest of the stay reports as other activity. - - - {HANDLING_FIELDS.map(([field, label]) => ( - - setHandling((prev) => ({ ...prev, [field]: v ? new Date(v) : null })) - } - maxDate={new Date()} - dropdownType={isSmallScreen ? "modal" : "popover"} - popoverProps={{ withinPortal: true }} - valueFormat="DD MMM YYYY HH:mm" - clearable - radius="md" - /> - ))} - -