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/INV-20260812-00005-mor.pdf b/INV-20260812-00005-mor.pdf new file mode 100644 index 000000000..4ce89c534 Binary files /dev/null and b/INV-20260812-00005-mor.pdf differ diff --git a/INV-20260812-00005-thermal.pdf b/INV-20260812-00005-thermal.pdf new file mode 100644 index 000000000..21df74ca7 Binary files /dev/null and b/INV-20260812-00005-thermal.pdf differ diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 0c6b1c727..f36b36382 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -18,6 +18,7 @@ "type-check": "tsc --noEmit", "seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts", "seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts", + "seed:warehouse-layout": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-layout.ts", "seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts", "seed:warehouse-export-receive-ready": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-export-receive-ready.ts", "seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts", @@ -31,6 +32,7 @@ "seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts", "seed:dropdown-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-dropdown-settings.ts", "seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts", + "seed:mor-test-buyers": "ts-node -r tsconfig-paths/register src/scripts/seed-mor-test-buyers.ts", "seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh", "iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js", "iam:migration:run": "pnpm run iam:typeorm:cli migration:run", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index a310e496b..84e3e4b7a 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -96,6 +96,7 @@ import { TrainsModule } from "./modules/trains/trains.module"; import { VerifaydaModule } from "./modules/verifayda/verifayda.module"; import { EimsModule } from "./modules/eims/eims.module"; import { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module"; +import { WagonHistoryModule } from "./modules/wagon-history/wagon-history.module"; import { WagonsModule } from "./modules/wagons/wagons.module"; import { ContainersModule } from "./modules/container-management/containers.module"; import { CargoesModule } from "./modules/cargoes/cargoes.module"; @@ -116,6 +117,7 @@ import { FacilitiesModule } from "./modules/facilities/facilities.module"; import { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.module"; import { FirstMileModule } from "./modules/first-mile/first-mile.module"; import { LastMileModule } from "./modules/last-mile/last-mile.module"; +import { EmptyReturnRequestsModule } from "./modules/empty-return-requests/empty-return-requests.module"; import { LastMileRequestsModule } from "./modules/last-mile-requests/last-mile-requests.module"; import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module"; import { ImportOperationsModule } from "./modules/import-operations/import-operations.module"; @@ -258,11 +260,13 @@ if (!process.env.APPLICATION_NAME) { FirstMileModule, LastMileModule, LastMileRequestsModule, + EmptyReturnRequestsModule, InterchangeDocumentsModule, ImportOperationsModule, VerifaydaModule, EimsModule, FleetHistoryModule, + WagonHistoryModule, AiModule, AuditModule, ChatModule, diff --git a/apps/edr-freight-api/src/common/freight-jwt.guard.spec.ts b/apps/edr-freight-api/src/common/freight-jwt.guard.spec.ts new file mode 100644 index 000000000..817e967f8 --- /dev/null +++ b/apps/edr-freight-api/src/common/freight-jwt.guard.spec.ts @@ -0,0 +1,148 @@ +import { + collectAllPositions, + resolveActiveEmployee, + type SnapshotEmployee, +} from './freight-jwt.guard'; + +// Shapes and ids taken from the real dev session for `test_dj_gl_director` +// (iam.sessions 800ad793-…), an employee holding two posts on one row. +const CHIEF = { + id: '990189f1-e872-4b8c-9f6a-36259a0df480', + employeePositionId: 'd0d527f6-f344-49aa-ab8b-25a448a770b6', + name: { en: 'Djibouti GL Chief' }, + isDelegate: false, +}; +const DIRECTOR = { + id: '258a8d82-28c4-401f-bf88-78f58bb6bd0e', + employeePositionId: 'b97aa265-5de8-4ffe-95bf-01f94d38a2df', + name: { en: 'Djibouti GL Director' }, + isDelegate: false, +}; + +const EMPLOYEE_ID = '70545ee5-c7d7-4196-af7e-a7eb7e76b21b'; +const oneRow: SnapshotEmployee[] = [ + { id: EMPLOYEE_ID, positions: [CHIEF, DIRECTOR] }, +]; + +describe('resolveActiveEmployee', () => { + it('leaves the parent guard alone when no position header is sent', () => { + const { owner, active } = resolveActiveEmployee( + oneRow, + undefined, + EMPLOYEE_ID, + ); + + expect(owner).toBe(oneRow[0]); + expect(active).toBeUndefined(); + }); + + it("resolves freight's header value (employeePositionId)", () => { + const { active } = resolveActiveEmployee( + oneRow, + DIRECTOR.employeePositionId, + EMPLOYEE_ID, + ); + + expect(active).toBe(DIRECTOR); + }); + + // The regression this guard exists for: the stock IAM guard matches the + // header against employeePositionId only, so Smart Office's position.id + // matched nothing and every request silently ran as positions[0]. + it("resolves Smart Office's header value (position.id)", () => { + const { active } = resolveActiveEmployee(oneRow, DIRECTOR.id, EMPLOYEE_ID); + + expect(active).toBe(DIRECTOR); + expect(active).not.toBe(CHIEF); + }); + + it('falls back to the parent row when the header names nothing', () => { + const { owner, active } = resolveActiveEmployee( + oneRow, + 'not-a-position-id', + EMPLOYEE_ID, + ); + + expect(owner).toBe(oneRow[0]); + expect(active).toBeUndefined(); + }); + + describe('when the two posts sit on different employee rows', () => { + const smartOfficeRow: SnapshotEmployee = { + id: 'emp-smart-office', + positions: [CHIEF], + }; + const freightRow: SnapshotEmployee = { + id: 'emp-freight', + positions: [DIRECTOR], + }; + const twoRows = [smartOfficeRow, freightRow]; + + it('selects the row that owns the requested position', () => { + const { owner, active } = resolveActiveEmployee( + twoRows, + DIRECTOR.employeePositionId, + // The parent guard matches the header against position.id only, so it + // matched neither row and fell through to the first. + smartOfficeRow.id, + ); + + expect(owner).toBe(freightRow); + expect(active).toBe(DIRECTOR); + }); + + it('keeps the parent row when no header is sent', () => { + const { owner } = resolveActiveEmployee(twoRows, undefined, freightRow.id); + + expect(owner).toBe(freightRow); + }); + + it('falls back to the first row when the parent row is unknown', () => { + const { owner } = resolveActiveEmployee(twoRows, undefined, undefined); + + expect(owner).toBe(smartOfficeRow); + }); + }); +}); + +describe('collectAllPositions', () => { + it('unions posts held across separate employee rows', () => { + // The real shape: IAM keeps one employee row per organization, and "EDR" + // and "EDR Freight" are separate orgs, so a user holding a Smart Office + // post and a freight post owns one row each. + const smartOfficeRow: SnapshotEmployee = { + id: 'emp-edr', + organizationId: 'org-edr', + positions: [CHIEF], + }; + const freightRow: SnapshotEmployee = { + id: 'emp-edr-freight', + organizationId: 'org-edr-freight', + positions: [DIRECTOR], + }; + + expect(collectAllPositions([smartOfficeRow, freightRow])).toEqual([ + CHIEF, + DIRECTOR, + ]); + }); + + it('keeps every post when they share one row', () => { + expect(collectAllPositions(oneRow)).toEqual([CHIEF, DIRECTOR]); + }); + + it('de-duplicates a post repeated across rows', () => { + const rows: SnapshotEmployee[] = [ + { id: 'a', positions: [CHIEF] }, + { id: 'b', positions: [CHIEF, DIRECTOR] }, + ]; + + expect(collectAllPositions(rows)).toEqual([CHIEF, DIRECTOR]); + }); + + it('tolerates rows carrying no positions', () => { + const rows: SnapshotEmployee[] = [{ id: 'a' }, { id: 'b', positions: [] }]; + + expect(collectAllPositions(rows)).toEqual([]); + }); +}); diff --git a/apps/edr-freight-api/src/common/freight-jwt.guard.ts b/apps/edr-freight-api/src/common/freight-jwt.guard.ts index 68b842217..9dbc36b73 100644 --- a/apps/edr-freight-api/src/common/freight-jwt.guard.ts +++ b/apps/edr-freight-api/src/common/freight-jwt.guard.ts @@ -3,29 +3,124 @@ 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 { CURRENT_POSITION_ID } from '@tria-plc/api-common/utils/constants/tenant.constant'; import { DataSource } from 'typeorm'; /** One position as the login snapshot stores it (`iam.sessions.userInfo`). */ -type SnapshotPosition = { id?: string; [key: string]: unknown }; +export type SnapshotPosition = { + id?: string; + employeePositionId?: string; + isDelegate?: boolean; + [key: string]: unknown; +}; -type SessionUserInfo = { - employee?: { id?: string; positions?: SnapshotPosition[] }[]; +/** One employee row as the snapshot stores it. A user may hold several. */ +export type SnapshotEmployee = { + id?: string; + positions?: SnapshotPosition[]; + [key: string]: unknown; +}; + +type SessionUserInfo = { employee?: SnapshotEmployee[] }; + +/** + * `x-current-position-id` is sent with two different meanings by two different + * frontends, and the IAM guard reads it both ways in the same function: it + * picks the EMPLOYEE row by `position.id` but the POSITION by + * `employeePositionId`. Freight sends `employeePositionId`, Smart Office sends + * `position.id` — so whichever value arrives, one of the two lookups silently + * matches nothing and falls back to the first entry. + * + * Matching both fields is what makes the header mean one thing again. + */ +const identifies = (position: SnapshotPosition, id: string): boolean => + position?.id === id || position?.employeePositionId === id; + +/** + * Every post the user holds, across every employee row, first occurrence kept. + * + * IAM keeps one employee row per ORGANIZATION, and "EDR" and "EDR Freight" are + * separate organizations — so a user given a freight post and a Smart Office + * post owns two rows, one post on each. Only one row can be the active one, and + * a permission check that reads only that row cannot see the other post at all. + */ +export const collectAllPositions = ( + employees: SnapshotEmployee[], +): SnapshotPosition[] => { + const seen = new Set(); + const all: SnapshotPosition[] = []; + + for (const employee of employees) { + for (const position of employee.positions ?? []) { + const key = position.employeePositionId ?? position.id; + if (key) { + if (seen.has(key)) continue; + seen.add(key); + } + all.push(position); + } + } + + return all; }; /** - * Like the IAM JwtGuard, but keeps the caller's SECONDARY positions. + * Which employee row the caller is acting as, and which of its positions the + * request selected. Pure so it can be tested without a session or a token. * - * 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. + * `owner` is the row holding the requested position; failing that the row the + * parent guard already picked; failing that the first. `active` is undefined + * when no header was sent or it names nothing — the caller then leaves the + * parent's choice of `employee.position` alone. + */ +export const resolveActiveEmployee = ( + employees: SnapshotEmployee[], + requestedId: string | undefined, + parentEmployeeId: string | undefined, +): { owner: SnapshotEmployee | undefined; active: SnapshotPosition | undefined } => { + const owner = + (requestedId && + employees.find((candidate) => + (candidate.positions ?? []).some((position) => + identifies(position, requestedId), + ), + )) || + employees.find( + (candidate) => candidate.id && candidate.id === parentEmployeeId, + ) || + employees[0]; + + const active = requestedId + ? (owner?.positions ?? []).find((position) => + identifies(position, requestedId), + ) + : undefined; + + return { owner, active }; +}; + +/** + * Like the IAM JwtGuard, but resolves the caller's position honestly. * - * 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. + * IAM models an employee as holding many positions — and a user as possibly + * holding several employee rows — and the login snapshot in + * `iam.sessions.userInfo` carries all of them. `JwtGuard.parseToken` collapses + * that to a single `employee.position` and drops the rest, so staff holding two + * posts resolve to one post's permissions and every check on the other one + * rejects them. + * + * This guard re-reads the snapshot and fixes three things the parent gets wrong: + * + * 1. re-attaches the full position list as `employee.positions`, which is what + * the permission utils union over; + * 2. selects the employee row that actually owns the requested position, so a + * post held on a second employee row is reachable at all; + * 3. sets `employee.position` to the requested position when the parent's + * one-sided id match missed it, keeping `auditUser` in step. + * + * Every correction is skipped unless the snapshot positively resolves it, so an + * unreadable session degrades to the parent's single-position behaviour rather + * than to no position at all. */ @Injectable() export class FreightJwtGuard extends IamJwtGuard implements CanActivate { @@ -35,7 +130,7 @@ export class FreightJwtGuard extends IamJwtGuard implements CanActivate { private static readonly CACHE_MAX_ENTRIES = 5_000; private readonly cache = new Map< string, - { positions: SnapshotPosition[]; expiresAt: number } + { employees: SnapshotEmployee[]; expiresAt: number } >(); constructor( @@ -48,44 +143,78 @@ export class FreightJwtGuard extends IamJwtGuard implements CanActivate { 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; + const request = context.switchToHttp().getRequest(); + const user = request.user as TCurrentUser | undefined; + const employee = user?.employee as SnapshotEmployee | undefined; if (!employee || !user?.sessionId) return true; - const positions = await this.positionsForSession( - user.sessionId, + const employees = await this.employeesForSession(user.sessionId); + if (!employees.length) return true; + + const requestedId = request.headers?.[CURRENT_POSITION_ID] as + | string + | undefined; + + const { owner, active } = resolveActiveEmployee( + employees, + requestedId, 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; + + const ownerPositions = owner?.positions ?? []; + // Never blank out what the parent resolved: a snapshot without positions + // must degrade to the single-position behaviour, not to no positions. + if (!ownerPositions.length) return true; + + // Carries the owning row's id / unitId / organizationId too, which unit + // scoping downstream reads — a swapped row must be swapped whole. + Object.assign(employee, owner); + + // `collectPermissionKeys` / `collectPositionTypeKeys` union over this, and + // a user's posts can span several employee rows (one per organization), so + // it carries every row's — otherwise a freight post is invisible whenever + // another organization's row wins the active slot. + employee.positions = collectAllPositions(employees); + + // Delegation stays scoped to the active desk: yard scope widens on + // `delegatedPositions`, and someone standing in on another organization's + // row is not this desk's stand-in. + employee.delegatedPositions = ownerPositions.filter( + (position) => position.isDelegate, + ); + + // The full set, for `/auth/me` — the position picker has to be able to + // offer a desk on a row that is not the active one. + (user as { employeeRows?: SnapshotEmployee[] }).employeeRows = employees; + + if (active) { + employee.position = active; + // The parent already built `auditUser` from the position it guessed. + if (request.auditUser) { + request.auditUser.employeeId = employee.id; + request.auditUser.positionId = active.id; + request.auditUser.employeePositionId = active.employeePositionId; + } } + return true; } - /** Every position the login snapshot holds for this employee. */ - private async positionsForSession( + /** Every employee row the login snapshot holds for this session. */ + private async employeesForSession( sessionId: string, - employeeId: string | undefined, - ): Promise { + ): Promise { const now = Date.now(); const hit = this.cache.get(sessionId); - if (hit && hit.expiresAt > now) return hit.positions; + if (hit && hit.expiresAt > now) return hit.employees; - let positions: SnapshotPosition[] = []; + let employees: SnapshotEmployee[] = []; 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 ?? []; + employees = rows[0]?.userInfo?.employee ?? []; } catch { return []; // iam unreachable — caller keeps the parent's single position } @@ -93,9 +222,9 @@ export class FreightJwtGuard extends IamJwtGuard implements CanActivate { if (this.cache.size >= FreightJwtGuard.CACHE_MAX_ENTRIES) this.cache.clear(); this.cache.set(sessionId, { - positions, + employees, expiresAt: now + FreightJwtGuard.CACHE_TTL_MS, }); - return positions; + return employees; } } diff --git a/apps/edr-freight-api/src/common/mile-haulage.util.spec.ts b/apps/edr-freight-api/src/common/mile-haulage.util.spec.ts index 8c3310321..c060130cd 100644 --- a/apps/edr-freight-api/src/common/mile-haulage.util.spec.ts +++ b/apps/edr-freight-api/src/common/mile-haulage.util.spec.ts @@ -1,4 +1,4 @@ -import { usesEdrMileService } from './mile-haulage.util'; +import { edrHaulsThisBooking, usesEdrMileService } from './mile-haulage.util'; /** * The road legs are chosen on the contract and copied onto the booking. EDR @@ -26,27 +26,76 @@ describe('usesEdrMileService', () => { }); it('an export that chose collection uses EDR haulage', () => { - expect( - usesEdrMileService(booking({ tradeDirection: 'EXPORT', firstMile: 'Modjo' })), - ).toBe(true); + expect(usesEdrMileService(booking({ tradeDirection: 'EXPORT', firstMile: 'Modjo' }))).toBe( + true, + ); }); it('ignores the delivery address on an export — delivery is the import leg', () => { - expect( - usesEdrMileService(booking({ tradeDirection: 'EXPORT', lastMile: 'Djibouti' })), - ).toBe(false); + expect(usesEdrMileService(booking({ tradeDirection: 'EXPORT', lastMile: 'Djibouti' }))).toBe( + false, + ); }); it('a domestic booking counts either leg', () => { - expect( - usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', firstMile: 'Adama' })), - ).toBe(true); - expect( - usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa' })), - ).toBe(true); + expect(usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', firstMile: 'Adama' }))).toBe( + true, + ); + expect(usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa' }))).toBe( + true, + ); }); it('treats a whitespace-only address as no choice', () => { expect(usesEdrMileService(booking({ lastMile: ' ' }))).toBe(false); }); }); + +/** + * Self-haul is closed only once EDR has committed to the leg. Delivery chosen + * on the contract is a request the chief still has to approve; collection has + * no approval step. + */ +describe('edrHaulsThisBooking', () => { + const booking = (over: Partial[0]> = {}) => ({ + tradeDirection: 'IMPORT', + firstMile: null, + lastMile: null, + lastMileCommitted: false, + ...over, + }); + + it('an import whose last-mile request is not yet approved may still self-haul', () => { + expect(edrHaulsThisBooking(booking({ lastMile: 'Bole, Addis Ababa' }))).toBe(false); + }); + + it('an import whose last-mile request was approved is hauled by EDR', () => { + expect( + edrHaulsThisBooking(booking({ lastMile: 'Bole, Addis Ababa', lastMileCommitted: true })), + ).toBe(true); + }); + + it('an import that chose no delivery self-hauls, whatever the leg tables say', () => { + expect(edrHaulsThisBooking(booking({ lastMileCommitted: true }))).toBe(false); + }); + + it('an export that chose collection is hauled by EDR — no approval step on that leg', () => { + expect(edrHaulsThisBooking(booking({ tradeDirection: 'EXPORT', firstMile: 'Modjo' }))).toBe( + true, + ); + }); + + it('a domestic booking is blocked by collection, or by an approved delivery', () => { + expect(edrHaulsThisBooking(booking({ tradeDirection: 'DOMESTIC', firstMile: 'Adama' }))).toBe( + true, + ); + expect( + edrHaulsThisBooking(booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa' })), + ).toBe(false); + expect( + edrHaulsThisBooking( + booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa', lastMileCommitted: true }), + ), + ).toBe(true); + }); +}); diff --git a/apps/edr-freight-api/src/common/mile-haulage.util.ts b/apps/edr-freight-api/src/common/mile-haulage.util.ts index 1ca83d06e..c9382d45c 100644 --- a/apps/edr-freight-api/src/common/mile-haulage.util.ts +++ b/apps/edr-freight-api/src/common/mile-haulage.util.ts @@ -39,7 +39,60 @@ export const SELF_HAUL_CONFLICT_MESSAGE = 'This booking is delivered by the customer’s own truck — an EDR mile leg cannot also be assigned.'; export const EDR_HAULAGE_CONFLICT_MESSAGE = - 'Customer truck assignment is only allowed when first/last mile delivery is not selected'; + 'Customer truck assignment is only allowed when first/last mile delivery is not selected, or when the EDR last-mile request has not been approved'; + +/** The booking fields that decide whether the customer may still bring their own truck. */ +export interface MileCommitmentRow extends MileHaulageRow { + /** + * EDR has actually committed to the delivery leg: the booking's last-mile + * request was approved, or a `freight.last_mile` leg row exists for it. + * Selecting delivery on the contract is only a request — see + * `edrHaulsThisBooking`. + */ + lastMileCommitted: boolean; +} + +/** + * SQL for `MileCommitmentRow.lastMileCommitted`, to be selected alongside the + * booking row aliased `b`. Both services that gate self-haul read the same + * fragment so the rule cannot drift between them. + */ +export const LAST_MILE_COMMITTED_SQL = `( + EXISTS (SELECT 1 + FROM freight.last_mile lm + WHERE lm.booking_id = b.id AND lm.deleted_at IS NULL) + OR EXISTS (SELECT 1 + FROM freight.last_mile_requests lmr + WHERE lmr.booking_id = b.id + AND lmr.deleted_at IS NULL + AND lmr.status = 'APPROVED') +)`; + +/** + * Whether EDR is hauling this booking's road leg, such that the customer may + * NOT assign their own truck. Stricter than `usesEdrMileService` on the + * delivery side: choosing last-mile delivery on the contract opens a request + * that the Truck & Machinery chief still has to approve, and until that + * approval the customer is free to self-haul instead. Collection (the export + * leg) has no approval step, so the contract choice alone decides it. + * + * `usesEdrMileService` keeps answering the other question — whether the booking + * belongs in the EDR mile queues at all — and the queue side still refuses a + * booking that already carries a customer truck, so the two paths remain + * mutually exclusive whichever acts first. + */ +export function edrHaulsThisBooking(booking: MileCommitmentRow): boolean { + const hasFirstMile = Boolean(booking.firstMile?.trim()); + const lastMileApproved = Boolean(booking.lastMile?.trim()) && booking.lastMileCommitted; + switch (booking.tradeDirection) { + case 'IMPORT': + return lastMileApproved; + case 'EXPORT': + return hasFirstMile; + default: + return hasFirstMile || lastMileApproved; + } +} /** * The road legs are chosen on the contract. A booking whose contract bought diff --git a/apps/edr-freight-api/src/common/truck-load.util.spec.ts b/apps/edr-freight-api/src/common/truck-load.util.spec.ts index fbb3a436a..de5ec9550 100644 --- a/apps/edr-freight-api/src/common/truck-load.util.spec.ts +++ b/apps/edr-freight-api/src/common/truck-load.util.spec.ts @@ -45,6 +45,16 @@ describe('assertTruckLoad', () => { ).toThrow(BadRequestException); }); + it('allows two containers only when both are explicitly 20ft', () => { + expect(() => + assertTruckLoad({ + containers: ['ABCD1234567', 'ABCD7654321'], + bookingContainers: booking, + sizes: ['20ft', '45ft'], + }), + ).toThrow(BadRequestException); + }); + it('rejects more than two containers', () => { expect(() => assertTruckLoad({ diff --git a/apps/edr-freight-api/src/common/truck-load.util.ts b/apps/edr-freight-api/src/common/truck-load.util.ts index b65bc12fb..6380786bd 100644 --- a/apps/edr-freight-api/src/common/truck-load.util.ts +++ b/apps/edr-freight-api/src/common/truck-load.util.ts @@ -54,10 +54,11 @@ export function assertTruckLoad({ } } - // A 40ft fills the bed, so it travels alone. - if (containers.length > 1 && sizes.some((size) => size.includes('40'))) { + // A truck may pair containers only when BOTH are explicitly 20ft. A 40ft + // (and any legacy/unknown larger size) fills the bed and travels alone. + if (containers.length > 1 && sizes.some((size) => !size.includes('20'))) { throw new BadRequestException( - 'A 40ft container fills the truck — assign only 1 container to this truck', + 'Truck capacity is either 1 x 40ft container or up to 2 x 20ft containers', ); } } diff --git a/apps/edr-freight-api/src/config/eims.config.spec.ts b/apps/edr-freight-api/src/config/eims.config.spec.ts index a6aa3895d..5e89822f4 100644 --- a/apps/edr-freight-api/src/config/eims.config.spec.ts +++ b/apps/edr-freight-api/src/config/eims.config.spec.ts @@ -26,6 +26,20 @@ const withEnv = (vars: Record, fn: () => void) => { }; describe("eims.config — private key / certificate resolution", () => { + it("requires EIMS_API_KEY when EIMS is enabled without exposing a value", () => { + withEnv( + { + ...REQUIRED, + EIMS_API_KEY: undefined, + EIMS_PRIVATE_KEY: "private-key-present", + EIMS_CERTIFICATE: "certificate-present", + }, + () => { + expect(() => eimsConfigFactory()).toThrow(/env vars are missing: EIMS_API_KEY/); + }, + ); + }); + it("unescapes a literal \\n when the PEM was pasted without real newlines", () => { withEnv( { ...REQUIRED, EIMS_PRIVATE_KEY: "line1\\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" }, diff --git a/apps/edr-freight-api/src/config/mor-location.resolver.spec.ts b/apps/edr-freight-api/src/config/mor-location.resolver.spec.ts index 0fc2607e2..77d230583 100644 --- a/apps/edr-freight-api/src/config/mor-location.resolver.spec.ts +++ b/apps/edr-freight-api/src/config/mor-location.resolver.spec.ts @@ -29,6 +29,8 @@ const FIXTURE: MorLocationTuple[] = [ [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 976, "Wal-Mera"], [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 909, "Akaki woreda"], [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1100, "WOREDA 1"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1102, "WOREDA 3"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1139, "WOREDA 7"], [253, "Djibouti", 1, "DJIBOUTI", 1, "DJIBOUTI VILLE", 1, "BALBALA"], ]; @@ -181,6 +183,93 @@ describe("resolveMorGeo", () => { }); }); + describe("Addis Ababa, where MoR has no zone tier", () => { + // e-Trade's real shape for a chartered city: `zone` repeats the region, the sub-city sits in + // `woreda`, and the numbered woreda sits in `kebele`. This is how every company imported from + // e-Trade stores an Addis Ababa address, and it is the shape that blocked INV-20260829-00011. + const ETRADE_SHAPE = { + country: "Ethiopia", + region: "Addis Ababa", + zone: "Addis Ababa", + woreda: "Kolfe-Keraniyo", + kebele: "07", + }; + + it("reads the sub-city and woreda one level down when the zone repeats the region", () => { + expect(resolveMorGeo(ETRADE_SHAPE, FIXTURE)).toEqual({ + Country: "70", + Region: "13", + City: "81", + Wereda: "1139", + }); + }); + + it("matches MoR's own 'KOLFIE KERANIYO' spelling of the sub-city", () => { + expect(resolveMorGeo({ ...ETRADE_SHAPE, woreda: "Kolfe Keranio" }, FIXTURE).City).toBe("81"); + }); + + it("reads a zero-padded number as MoR's 'WOREDA n' locality, in either slot", () => { + const bole = { country: "Ethiopia", region: "Addis Ababa", zone: "Bole" }; + expect(resolveMorGeo({ ...bole, woreda: "03" }, FIXTURE).Wereda).toBe("1102"); + expect(resolveMorGeo({ ...bole, woreda: "Woreda 03" }, FIXTURE).Wereda).toBe("1102"); + expect(resolveMorGeo({ ...bole, woreda: "WOREDA 3" }, FIXTURE).Wereda).toBe("1102"); + }); + + it("still resolves the already-correct shape without shifting", () => { + expect( + resolveMorGeo( + { country: "Ethiopia", region: "ADDIS ABABA", zone: "BOLE", woreda: "WOREDA 1" }, + FIXTURE, + ), + ).toEqual({ Country: "70", Region: "13", City: "78", Wereda: "1100" }); + }); + + it("reports the zone failure, not the shifted one, when the shift does not resolve", () => { + // LEMI KURA is a 2020 sub-city the Ministry sheet does not list. The shift must not turn + // that into a confusing locality error, and must never land on a neighbouring sub-city. + expect(() => + resolveMorGeo({ ...ETRADE_SHAPE, woreda: "Lemi Kura", kebele: "02" }, FIXTURE), + ).toThrow(/no MoR CITY_NAME match for country="Ethiopia", region="Addis Ababa"/); + }); + + it("does not shift when the zone is simply an unknown zone", () => { + expect(() => + resolveMorGeo( + { + country: "Ethiopia", + region: "OROMIA", + zone: "East Zone", + woreda: "KERSA", + kebele: "01", + }, + FIXTURE, + ), + ).toThrow(/no MoR CITY_NAME match/); + }); + }); + + it("resolves the regions and zones MoR spells differently from e-Trade", () => { + // Guards the reviewed alias table: MoR's PARISH_NAME is "AMAHARA", and it keeps the Amharic + // compass words for the Oromia zones ("MISRAK SHOA" for East Shewa). + const rows: MorLocationTuple[] = [ + ...FIXTURE, + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 21, "ADAMA"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 149, "MECHA"], + ]; + expect( + resolveMorGeo( + { country: "Ethiopia", region: "Oromia", zone: "East Shewa", woreda: "Adama" }, + rows, + ), + ).toEqual({ Country: "70", Region: "2", City: "16", Wereda: "21" }); + expect( + resolveMorGeo( + { country: "Ethiopia", region: "Amhara", zone: "West Gojjam", woreda: "Mecha" }, + rows, + ), + ).toEqual({ Country: "70", Region: "11", City: "53", Wereda: "149" }); + }); + describe("failures happen locally, before anything is filed", () => { const cases: Array<[string, Record, RegExp]> = [ ["unknown country", { ...JIJIGA, country: "Wakanda" }, /no MoR COUNTRY_NAME match/], diff --git a/apps/edr-freight-api/src/config/mor-location.resolver.ts b/apps/edr-freight-api/src/config/mor-location.resolver.ts index 72459db9b..0b0cef6e2 100644 --- a/apps/edr-freight-api/src/config/mor-location.resolver.ts +++ b/apps/edr-freight-api/src/config/mor-location.resolver.ts @@ -43,6 +43,11 @@ export interface MorAddressInput { region?: string | null; zone?: string | null; woreda?: string | null; + /** + * Only read for the city-region shift below — in Addis Ababa e-Trade stores the numbered woreda + * here. Never consulted for an ordinary region/zone/woreda address. + */ + kebele?: string | null; } type Level = "country" | "region" | "zone" | "woreda"; @@ -115,6 +120,24 @@ const ALIASES: MorAlias[] = [ from: "Jigjiga", to: "JIJIGA", }, + // MoR misspells the region itself — PARISH_NO 11 is "AMAHARA". No other parish is close to it. + { level: "region", from: "Amhara", to: "AMAHARA" }, + // Addis Ababa sub-cities, where MoR's sheet and e-Trade disagree on spelling. Each confirmed by + // CITY_NO under PARISH_NO 13; the seven that already agree (ARADA, ADDIS KETEMA, LIDETA, KIRKOS, + // YEKA, BOLE, GULLELE) need no entry. LEMI KURA is deliberately absent — the Ministry sheet does + // not list the 2020 split at all, so it must keep failing rather than be mapped onto a neighbour. + { level: "zone", region: "ADDIS ABABA", from: "Kolfe Keraniyo", to: "KOLFIE KERANIYO" }, // 81 + { level: "zone", region: "ADDIS ABABA", from: "Kolfe Keranio", to: "KOLFIE KERANIYO" }, // 81 + { level: "zone", region: "ADDIS ABABA", from: "Nifas Silk Lafto", to: "NEFAS SILK LAFTO" }, // 80 + { level: "zone", region: "ADDIS ABABA", from: "Akaki Kality", to: "AKAKI KALITI" }, // 79 + // MoR keeps the Amharic compass words for the Oromia zones; e-Trade stores the English ones. + // Each pair confirmed by the zone's own localities in the sheet: MISRAK SHOA holds ADAMA and + // BISHOFTU, MIRAB SHOA holds AMBO and WELMERA, MIRAB HARARGE holds CHIRO and GEMMECHIS. + { level: "zone", region: "OROMIA", from: "East Shewa", to: "MISRAK SHOA" }, // 16 + { level: "zone", region: "OROMIA", from: "West Shewa", to: "MIRAB SHOA" }, // 62 + { level: "zone", region: "OROMIA", from: "West Hararge", to: "MIRAB HARARGE" }, // 7 + // MoR drops a J. Confirmed by BAHIRDAR ZURIA / MECHA / BURIE sitting under CITY_NO 53. + { level: "zone", region: "AMAHARA", from: "West Gojjam", to: "WEST GOJAM" }, // 53 ]; /** @@ -127,6 +150,20 @@ const ALIASES: MorAlias[] = [ const zoneSuffixCandidates = (normalized: string): string[] => normalized.endsWith(" ZONE") ? [] : [`${normalized} ZONE`]; +/** + * In the chartered cities MoR names each locality "WOREDA 7", while e-Trade stores the bare, + * zero-padded number ("07") and EDR's own forms sometimes store "Woreda 05". All three mean the + * same locality, so the MoR spelling is tried as a second exact-match candidate — MoR writes no + * leading zero, hence the strip. Applied to the locality level only. + * + * This runs ahead of the numeric LOCALITY_NO fallback below, and can never mask it: no city in the + * Ministry sheet contains both a "WOREDA n" locality and a locality whose LOCALITY_NO is n. + */ +const woredaNumberCandidates = (normalized: string): string[] => { + const match = /^(?:WOREDA )?0*([0-9]{1,2})$/.exec(normalized); + return match ? [`WOREDA ${match[1]}`] : []; +}; + export class MorGeoMappingError extends BadRequestException { constructor(code: "EIMS_GEO_MAPPING_FAILED" | "EIMS_GEO_AMBIGUOUS", message: string) { super({ code, message }); @@ -158,6 +195,7 @@ function matchLevel( if (normalizeName(alias.from) === wanted) candidates.push(normalizeName(alias.to)); } if (level === "zone") candidates.push(...zoneSuffixCandidates(wanted)); + if (level === "woreda") candidates.push(...woredaNumberCandidates(wanted)); } let matched: MorLocationTuple[] = []; @@ -221,25 +259,46 @@ export function resolveMorGeo( const inCountry = matchLevel(rows, "country", country, {}, input); const inRegion = matchLevel(inCountry.rows, "region", input.region, {}, input); const regionScope = normalizeName(inRegion.rows[0][SLOTS.region.name] as string); - const inZone = matchLevel(inRegion.rows, "zone", input.zone, { region: regionScope }, input); - const zoneScope = normalizeName(inZone.rows[0][SLOTS.zone.name] as string); - const inWoreda = matchLevel( - inZone.rows, - "woreda", - input.woreda, - { - region: regionScope, - zone: zoneScope, - }, - input, - ); - return { - Country: String(inCountry.no), - Region: String(inRegion.no), - City: String(inZone.no), - Wereda: String(inWoreda.no), + type Name = string | null | undefined; + const below = (zone: Name, woreda: Name): MorGeoCodes => { + const inZone = matchLevel(inRegion.rows, "zone", zone, { region: regionScope }, input); + const zoneScope = normalizeName(inZone.rows[0][SLOTS.zone.name] as string); + const inWoreda = matchLevel( + inZone.rows, + "woreda", + woreda, + { + region: regionScope, + zone: zoneScope, + }, + input, + ); + return { + Country: String(inCountry.no), + Region: String(inRegion.no), + City: String(inZone.no), + Wereda: String(inWoreda.no), + }; }; + + try { + return below(input.zone, input.woreda); + } catch (err) { + // Addis Ababa (and every other chartered city) has no zone tier: MoR's CITY level *is* the + // sub-city and its LOCALITY level is the numbered woreda. e-Trade fills the missing tier by + // repeating the region in `zone`, which pushes the sub-city into `woreda` and the woreda + // number into `kebele` — one level down the whole way. Retry with that reading, but only when + // `zone` genuinely repeats the region, and only accept it when *both* shifted levels resolve + // exactly. A zone MoR simply does not list still fails with its own message, unreinterpreted. + const zone = normalizeName(input.zone); + if (!zone || (zone !== regionScope && zone !== normalizeName(input.region))) throw err; + try { + return below(input.woreda, input.kebele); + } catch { + throw err; + } + } } /** Non-throwing variant for callers that already have a working fallback (the seller identity). */ 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/migrations/3790000000000-EmptyContainerReturnCompanyName.ts b/apps/edr-freight-api/src/migrations/3790000000000-EmptyContainerReturnCompanyName.ts new file mode 100644 index 000000000..b0088da66 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3790000000000-EmptyContainerReturnCompanyName.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Empties backfilled into the yard belong to a company that may not be a + * registered customer yet, so `customer_id` cannot hold it. `company_name` is + * the typed fallback, and the display label when the customer IS registered. + */ +export class EmptyContainerReturnCompanyName3790000000000 implements MigrationInterface { + name = 'EmptyContainerReturnCompanyName3790000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.empty_container_returns + ADD COLUMN IF NOT EXISTS company_name varchar(200) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.empty_container_returns + DROP COLUMN IF EXISTS company_name + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3800000000000-WarehouseInventoryBacklogRegistration.ts b/apps/edr-freight-api/src/migrations/3800000000000-WarehouseInventoryBacklogRegistration.ts new file mode 100644 index 000000000..ba4ba7cdb --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3800000000000-WarehouseInventoryBacklogRegistration.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Backlog registration of full containers that were already sitting in a yard + * before the system knew about them. Such a row carries a true, backdated + * `arrived_at` for the record but accrues NO storage or demurrage — the + * operator decided these are not billable retroactively — so the flag exists + * to keep the fee engine off them. + * + * `company_id` / `company_name` carry the owner, since a backlog row has no + * booking to inherit one from. The name is free text for a company that is not + * a registered customer yet. + */ +export class WarehouseInventoryBacklogRegistration3800000000000 implements MigrationInterface { + name = 'WarehouseInventoryBacklogRegistration3800000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.warehouse_inventory + ADD COLUMN IF NOT EXISTS backlog_registration boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS company_id uuid, + ADD COLUMN IF NOT EXISTS company_name varchar(200) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.warehouse_inventory + DROP COLUMN IF EXISTS backlog_registration, + DROP COLUMN IF EXISTS company_id, + DROP COLUMN IF EXISTS company_name + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3810000000000-WarehouseZoneDeletePermission.ts b/apps/edr-freight-api/src/migrations/3810000000000-WarehouseZoneDeletePermission.ts new file mode 100644 index 000000000..fa2bcf7ec --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3810000000000-WarehouseZoneDeletePermission.ts @@ -0,0 +1,55 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Seed `edr_freight_app:warehouse_zones:delete` — the zone counterpart of the + * warehouse and yard delete permissions, which already exist. + * + * `ROLE_PERMISSION_PRESETS` spreads `Object.values(FREIGHT_PERMS.warehouseZones)` + * into the warehouse positions, so the moment the key is added to the registry + * `FreightPositionsSeeder.loadPermissionIds` resolves it against `iam.permissions` + * at boot — and throws `missing_permissions:` if the row is absent. The + * catalog is otherwise written by `EdrOrgSeeder`, which skips itself unless + * `SEED_EDR_ORG` is set, so a migration is the only path that runs everywhere. + * + * Idempotent on `key`; keeps the registry's fixed uuid so every environment + * lands on the same id. Skips silently when the freight application row is + * absent, since there is nothing to attach to. + */ +export class WarehouseZoneDeletePermission3810000000000 implements MigrationInterface { + private static readonly KEY = 'edr_freight_app:warehouse_zones:delete'; + private static readonly ID = 'f1c00001-0001-4000-8000-000000000004'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `INSERT INTO iam.permissions (id, key, name, application_id) + SELECT $2::uuid, + $1::varchar, + '{"am": "Delete warehouse zone", "en": "Delete warehouse zone"}'::jsonb, + a.id + FROM iam.application a + WHERE a.key = 'edr_freight_app' + AND NOT EXISTS (SELECT 1 FROM iam.permissions p WHERE p.key = $1::varchar)`, + [WarehouseZoneDeletePermission3810000000000.KEY, WarehouseZoneDeletePermission3810000000000.ID], + ); + } + + /** + * Grants go first, or the delete trips the position/role permission foreign + * keys — a half-removed permission is worse than one left in place. + */ + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM iam.position_permissions + WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`, + [WarehouseZoneDeletePermission3810000000000.KEY], + ); + await queryRunner.query( + `DELETE FROM iam.role_permissions + WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`, + [WarehouseZoneDeletePermission3810000000000.KEY], + ); + await queryRunner.query(`DELETE FROM iam.permissions WHERE key = $1`, [ + WarehouseZoneDeletePermission3810000000000.KEY, + ]); + } +} diff --git a/apps/edr-freight-api/src/migrations/3820000000000-WagonEvents.ts b/apps/edr-freight-api/src/migrations/3820000000000-WagonEvents.ts new file mode 100644 index 000000000..014df7491 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3820000000000-WagonEvents.ts @@ -0,0 +1,60 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Unified per-wagon history ledger. One append-only row per transition + * (yard move, coupling, schedule pin/dispatch/release, status flip, cargo + * load/unload, container placement, lifecycle edits), written in the same + * transaction as the change. No foreign keys: history must survive the wagon, + * train, schedule or booking it points at. The two composite indexes back + * keyset pagination of a single wagon's timeline (optionally per category); + * the partial ones answer "what happened on this schedule / booking". + */ +export class WagonEvents3820000000000 implements MigrationInterface { + name = 'WagonEvents3820000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.wagon_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + wagon_id uuid NOT NULL, + wagon_number varchar, + event_type varchar(40) NOT NULL, + category varchar(20) NOT NULL, + occurred_at timestamptz NOT NULL DEFAULT now(), + actor_user_id uuid, + from_yard_id uuid, + to_yard_id uuid, + train_id uuid, + train_schedule_id uuid, + booking_id uuid, + from_value varchar(120), + to_value varchar(120), + reason text, + metadata jsonb, + created_at timestamptz NOT NULL DEFAULT now() + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagon_events_wagon_time + ON freight.wagon_events (wagon_id, occurred_at DESC, id DESC) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagon_events_wagon_cat_time + ON freight.wagon_events (wagon_id, category, occurred_at DESC, id DESC) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagon_events_schedule + ON freight.wagon_events (train_schedule_id) + WHERE train_schedule_id IS NOT NULL + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagon_events_booking + ON freight.wagon_events (booking_id) + WHERE booking_id IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_events`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3820000000000-WarehouseFreightType.ts b/apps/edr-freight-api/src/migrations/3820000000000-WarehouseFreightType.ts new file mode 100644 index 000000000..563128411 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3820000000000-WarehouseFreightType.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * `freight.warehouses.freight_type` — CONTAINER or BULK, or null for a site + * that takes both. + * + * Nullable with no backfill on purpose: every existing warehouse predates the + * field and is unrestricted today, so writing a value would narrow live + * allocation behind the operator's back. + */ +export class WarehouseFreightType3820000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.warehouses ADD COLUMN IF NOT EXISTS freight_type varchar(16)`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE freight.warehouses DROP COLUMN IF EXISTS freight_type`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3830000000000-WarehouseZoneStacksSlots.ts b/apps/edr-freight-api/src/migrations/3830000000000-WarehouseZoneStacksSlots.ts new file mode 100644 index 000000000..38341a1f1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3830000000000-WarehouseZoneStacksSlots.ts @@ -0,0 +1,128 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Physical container positions below the zone: a stack is the ground footprint, + * a slot is one level in it. Adds `stack_id` / `slot_id` to warehouse inventory. + * + * Everything is additive and nullable. Existing inventory keeps warehouse / + * yard / zone as its only location and stays valid — nothing is backfilled, + * because no one can know where a box already in the yard is actually stacked. + * + * Occupancy is not stored on the slot. `uq_warehouse_inventory_active_slot` + * makes the inventory row the single source of truth: one live placement per + * slot, enforced by Postgres. Its status list must stay in step with + * `SLOT_OCCUPYING_STATUSES` in warehouse-inventory.entity.ts. + */ +export class WarehouseZoneStacksSlots3830000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_zone_stacks ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + zone_id uuid NOT NULL REFERENCES freight.warehouse_zones(id) ON DELETE CASCADE, + code varchar(40) NOT NULL, + name varchar(160), + "row" varchar(20), + bay varchar(20), + "position" varchar(20), + max_stack_height int NOT NULL DEFAULT 3, + status varchar(16) NOT NULL DEFAULT 'ACTIVE', + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT chk_warehouse_zone_stacks_height CHECK (max_stack_height >= 1) + ) + `); + + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_warehouse_zone_stacks_zone ON freight.warehouse_zone_stacks (zone_id)`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_warehouse_zone_stacks_status ON freight.warehouse_zone_stacks (status)`, + ); + // Partial: a soft-deleted stack must not block reusing its code. + await queryRunner.query( + `CREATE UNIQUE INDEX IF NOT EXISTS uq_warehouse_zone_stacks_zone_code + ON freight.warehouse_zone_stacks (zone_id, code) WHERE deleted_at IS NULL`, + ); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_zone_slots ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + stack_id uuid NOT NULL REFERENCES freight.warehouse_zone_stacks(id) ON DELETE CASCADE, + level int NOT NULL, + status varchar(16) NOT NULL DEFAULT 'AVAILABLE', + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT chk_warehouse_zone_slots_level CHECK (level >= 1) + ) + `); + + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_warehouse_zone_slots_stack ON freight.warehouse_zone_slots (stack_id, level)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX IF NOT EXISTS uq_warehouse_zone_slots_stack_level + ON freight.warehouse_zone_slots (stack_id, level) WHERE deleted_at IS NULL`, + ); + + await queryRunner.query( + `ALTER TABLE freight.warehouse_inventory ADD COLUMN IF NOT EXISTS stack_id uuid`, + ); + await queryRunner.query( + `ALTER TABLE freight.warehouse_inventory ADD COLUMN IF NOT EXISTS slot_id uuid`, + ); + + // Named FKs added defensively — ADD CONSTRAINT has no IF NOT EXISTS. + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.warehouse_inventory + ADD CONSTRAINT fk_warehouse_inventory_stack + FOREIGN KEY (stack_id) REFERENCES freight.warehouse_zone_stacks(id); + EXCEPTION WHEN duplicate_object THEN NULL; END $$ + `); + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.warehouse_inventory + ADD CONSTRAINT fk_warehouse_inventory_slot + FOREIGN KEY (slot_id) REFERENCES freight.warehouse_zone_slots(id); + EXCEPTION WHEN duplicate_object THEN NULL; END $$ + `); + + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_stack ON freight.warehouse_inventory (stack_id)`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_slot ON freight.warehouse_inventory (slot_id)`, + ); + + // One live container per slot. Statuses past the yard gate (LOADED, + // DISPATCHED, DELIVERED, UNLOADED_AT_DJIBOUTI_PORT) free the position + // without any exit path having to clear the column. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_warehouse_inventory_active_slot + ON freight.warehouse_inventory (slot_id) + WHERE deleted_at IS NULL + AND slot_id IS NOT NULL + AND status IN ('UNLOADED','RECEIVED','STORED','RESERVED','READY_FOR_LOADING','READY_FOR_PICKUP') + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_warehouse_inventory_active_slot`); + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_inventory_slot`); + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_inventory_stack`); + await queryRunner.query( + `ALTER TABLE freight.warehouse_inventory DROP CONSTRAINT IF EXISTS fk_warehouse_inventory_slot`, + ); + await queryRunner.query( + `ALTER TABLE freight.warehouse_inventory DROP CONSTRAINT IF EXISTS fk_warehouse_inventory_stack`, + ); + await queryRunner.query(`ALTER TABLE freight.warehouse_inventory DROP COLUMN IF EXISTS slot_id`); + await queryRunner.query(`ALTER TABLE freight.warehouse_inventory DROP COLUMN IF EXISTS stack_id`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_zone_slots`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_zone_stacks`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3840000000000-EmptyReturnRequests.ts b/apps/edr-freight-api/src/migrations/3840000000000-EmptyReturnRequests.ts new file mode 100644 index 000000000..157b95570 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3840000000000-EmptyReturnRequests.ts @@ -0,0 +1,67 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Customer-initiated empty container return, for a booking that did NOT buy + * the return service up front. The customer names the containers coming back, + * operations approves and prices it off the contract's WITH_RETURN rate, the + * customer pays that invoice and then books the date and truck. The empty + * itself is still recorded through `empty_container_returns` when the truck + * actually arrives — this table only carries the request up to that point. + */ +export class EmptyReturnRequests3840000000000 implements MigrationInterface { + name = 'EmptyReturnRequests3840000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.empty_return_requests ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + booking_id uuid NOT NULL, + company_id uuid, + status varchar(30) NOT NULL DEFAULT 'SUBMITTED', + container_numbers text[] NOT NULL DEFAULT '{}', + container_count smallint NOT NULL DEFAULT 0, + quoted_unit_amount numeric(14,2), + quoted_total_amount numeric(14,2), + currency varchar(8), + invoice_id uuid, + paid_at timestamptz, + requested_return_date date, + truck_plate_number varchar(32), + truck_driver_name varchar(120), + truck_type varchar(60), + scheduled_at timestamptz, + submitted_by_user_id uuid, + submitted_at timestamptz NOT NULL DEFAULT now(), + reviewed_by_staff_id uuid, + reviewed_at timestamptz, + rejection_reason text, + completed_at timestamptz, + 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_empty_return_requests_booking + ON freight.empty_return_requests (booking_id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_empty_return_requests_status + ON freight.empty_return_requests (status) + `); + + // A container number may only be owed back once at a time. That guard is + // per array element, so it lives in the service (see assertContainersFree) + // rather than in a unique index — this GIN index is what makes the check + // cheap. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_empty_return_requests_containers + ON freight.empty_return_requests USING gin (container_numbers) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.empty_return_requests`); + } +} diff --git a/apps/edr-freight-api/src/modules/audit/audit.interceptor.ts b/apps/edr-freight-api/src/modules/audit/audit.interceptor.ts index cf12fd4d3..8b5c82e9c 100644 --- a/apps/edr-freight-api/src/modules/audit/audit.interceptor.ts +++ b/apps/edr-freight-api/src/modules/audit/audit.interceptor.ts @@ -4,24 +4,17 @@ import { HttpException, Injectable, NestInterceptor, -} from '@nestjs/common'; -import { Observable, tap } from 'rxjs'; -import type { Request, Response } from 'express'; +} from "@nestjs/common"; +import { Observable, tap } from "rxjs"; +import type { Request, Response } from "express"; -import { AuditService } from './audit.service'; -import { - auditEndpointMatcher, - type MatchedAuditEndpoint, -} from './audit-endpoint-matcher'; -import { - isAuditableActor, - resolveAuditActor, - type AuditActorSource, -} from './audit-actor'; -import { redactUrlQuery, sanitizeRequestPayload } from './audit.sanitizer'; +import { AuditService } from "./audit.service"; +import { auditEndpointMatcher, type MatchedAuditEndpoint } from "./audit-endpoint-matcher"; +import { isAuditableActor, resolveAuditActor, type AuditActorSource } from "./audit-actor"; +import { redactUrlQuery, sanitizeRequestPayload } from "./audit.sanitizer"; /** Methods that can change state. Everything else is never audited. */ -const AUDITED_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); +const AUDITED_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); /** `error_message` ceiling — stack traces do not belong in this column. */ const MAX_ERROR_LENGTH = 2_000; @@ -52,7 +45,7 @@ export class AuditInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable { // Non-HTTP contexts (the RabbitMQ microservice transport) have no request. - if (context.getType() !== 'http') return next.handle(); + if (context.getType() !== "http") return next.handle(); const httpContext = context.switchToHttp(); const request = httpContext.getRequest(); @@ -72,10 +65,7 @@ export class AuditInterceptor implements NestInterceptor { const startedAt = Date.now(); // The body is captured up front: handlers are free to mutate the DTO they // are given, so reading it after the fact can record post-mutation values. - const requestPayload = sanitizeRequestPayload( - request.body, - request.files ?? request.file, - ); + const requestPayload = sanitizeRequestPayload(request.body, request.files ?? request.file); return next.handle().pipe( tap({ @@ -137,7 +127,6 @@ export class AuditInterceptor implements NestInterceptor { resourceId: matched.resourceId, request: requestPayload, ipAddress: resolveIp(request), - userAgent: request.headers['user-agent'] ?? null, requestId: resolveRequestId(request), durationMs: Date.now() - startedAt, }); @@ -154,10 +143,10 @@ function resolveErrorMessage(error: unknown): string | null { if (error instanceof HttpException) { const response = error.getResponse(); const message = - typeof response === 'string' + typeof response === "string" ? response : ((response as { message?: unknown })?.message ?? error.message); - const text = Array.isArray(message) ? message.join('; ') : String(message); + const text = Array.isArray(message) ? message.join("; ") : String(message); return text.slice(0, MAX_ERROR_LENGTH); } @@ -171,19 +160,19 @@ function resolveErrorMessage(error: unknown): string | null { * entry (the original client) taken. */ function resolveIp(request: Request): string | null { - const forwarded = request.headers['x-forwarded-for']; + const forwarded = request.headers["x-forwarded-for"]; const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded; - const candidate = raw?.split(',')[0]?.trim() || request.ip; + const candidate = raw?.split(",")[0]?.trim() || request.ip; if (!candidate) return null; // Normalize IPv4-mapped IPv6 (`::ffff:10.0.0.1`), which the `inet` column // accepts but which reads badly and breaks grouping by address. - return candidate.startsWith('::ffff:') ? candidate.slice(7) : candidate; + return candidate.startsWith("::ffff:") ? candidate.slice(7) : candidate; } /** Correlation id from the proxy/tracing layer, when present. */ function resolveRequestId(request: RequestWithUser): string | null { - const header = request.headers['x-request-id'] ?? request.headers['x-correlation-id']; + const header = request.headers["x-request-id"] ?? request.headers["x-correlation-id"]; const value = Array.isArray(header) ? header[0] : header; return (value ?? request.id ?? null)?.toString().slice(0, 64) ?? null; } 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.service.ts b/apps/edr-freight-api/src/modules/auth/freight-me.service.ts index b897c5fef..0195bba47 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 @@ -3,6 +3,7 @@ import { InjectDataSource } from '@nestjs/typeorm'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { DataSource } from 'typeorm'; +import type { SnapshotEmployee } from '../../common/freight-jwt.guard'; import { collectPermissionKeys, isSuperAdmin, @@ -82,8 +83,26 @@ export class FreightMeService { ? [employeeRecord.position] : []; - const enrichedPositions = await Promise.all( - rawPositions.map(async (position) => { + // IAM keeps one employee row per organization, so a user holding a freight + // post and a Smart Office post owns two rows. The backoffice reads + // `employee` as an array and the position picker lists what it finds there + // — returning only the active row hides the other desk and makes it + // unselectable. `FreightJwtGuard` leaves the full set here. + const employeeRows = (user as { employeeRows?: SnapshotEmployee[] }) + .employeeRows; + + // Active row first: the backoffice reads `employee[0]` for + // unitId/organizationId, so the desk the caller is acting as must lead. + const rows: SnapshotEmployee[] = employeeRows?.length + ? [ + ...employeeRows.filter((row) => row.id === employeeRecord?.id), + ...employeeRows.filter((row) => row.id !== employeeRecord?.id), + ] + : employeeRecord + ? [{ ...employeeRecord, positions: rawPositions } as SnapshotEmployee] + : []; + + const enrichPosition = async (position: TokenPosition) => { const [positionType, positionTypePermissionKeys] = await Promise.all([ this.lookupPositionType(position.id), this.lookupPositionTypePermissions(position.id), @@ -114,20 +133,24 @@ export class FreightMeService { positionType, }, }; - }), + }; + + const enrichedRows = await Promise.all( + rows.map(async (row) => ({ + row, + positions: await Promise.all( + ((row.positions ?? []) as TokenPosition[]).map(enrichPosition), + ), + })), ); - const employee = employeeRecord - ? [ - { - id: employeeRecord.id, - organizationId: employeeRecord.organizationId, - unitId: employeeRecord.unitId, - name: employeeRecord.name, - positions: enrichedPositions.map((p) => p.position), - }, - ] - : []; + const employee = enrichedRows.map(({ row, positions }) => ({ + id: row.id as string, + organizationId: row.organizationId as string, + unitId: row.unitId as string, + name: row.name, + positions: positions.map((p) => p.position), + })); // `collectPermissionKeys` reads the raw token (position-level only), so // union the type-level grants in — the backoffice prefers this flat list @@ -135,7 +158,9 @@ export class FreightMeService { const permissionKeys = [ ...new Set([ ...collectPermissionKeys(user), - ...enrichedPositions.flatMap((p) => p.positionTypePermissionKeys), + ...enrichedRows.flatMap(({ positions }) => + positions.flatMap((p) => p.positionTypePermissionKeys), + ), ]), ]; diff --git a/apps/edr-freight-api/src/modules/billing/billing.module.ts b/apps/edr-freight-api/src/modules/billing/billing.module.ts index 06849d560..d29d52c42 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.module.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.module.ts @@ -14,6 +14,8 @@ import { InvoiceLineRepository } from "./invoice-line.repository"; import { PaymentModule } from "../payment/payment.module"; import { CompaniesModule } from "../companies/companies.module"; import { FilesModule } from "../files/files.module"; +import { NotificationsModule } from "../notifications/notifications.module"; +import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module"; @Module({ imports: [ @@ -24,6 +26,10 @@ import { FilesModule } from "../files/files.module"; DocumentsModule, UserTradeAccessModule, FilesModule, + // Customer notice when Finance confirms a manual payment. The inbox module + // reaches this one back through CompaniesModule, hence forwardRef. + NotificationsModule, + forwardRef(() => NotificationInboxModule), ], controllers: [BillingController, PortalBillingController, PaymentController], providers: [BillingService, InvoiceRepository, InvoiceLineRepository], 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..9a34516aa 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 @@ -83,6 +83,8 @@ describe("BillingService.generateInvoice", () => { {} as never, // files { get: () => undefined } as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); }); @@ -166,6 +168,8 @@ describe("BillingService.issueMemo", () => { {} as never, { get: () => undefined } as never, { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); return { service, manager, savedLines }; } @@ -301,6 +305,8 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, // files { get: () => undefined } as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -357,6 +363,8 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, // files { get: () => undefined } as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -403,6 +411,8 @@ describe("BillingService.settleByPaymentId", () => { {} as never, // files { get: () => undefined } as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); return { service, mg, events }; } @@ -517,6 +527,8 @@ describe("BillingService.recordPayment", () => { {} as never, // files { get: () => undefined } as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); return { service, mg, events }; } @@ -635,6 +647,8 @@ describe("BillingService.expirePayable — locked write runs in a transaction", {} as never, {} as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); return { service, defaultManager, txManager, transaction }; }; @@ -709,6 +723,8 @@ describe("BillingService.issuePayable", () => { {} as never, {} as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); return { service, manager }; }; @@ -801,6 +817,8 @@ describe("BillingService — CAC Bank (OTP debit)", () => { {} as never, {} as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); return { service, repo }; }; @@ -885,6 +903,8 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () => {} as never, {} as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); return { service, repo }; }; @@ -939,8 +959,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, @@ -955,6 +979,8 @@ describe("BillingService.document", () => { : undefined, } as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); return { service, render, renderThermal }; }; @@ -1014,6 +1040,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); @@ -1047,6 +1101,8 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => { function makeService(invoiceType: string) { const invoice = { id: "inv-1", + invoiceNumber: "INV-001", + companyId: "company-1", source: Freight.InvoiceSource.Booking, sourceId: "booking-1", type: invoiceType, @@ -1057,9 +1113,16 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => { const recordPayment = jest.fn().mockResolvedValue(invoice); const dataSource = { getRepository: () => ({ - findOne: async () => ({ id: "booking-1", paymentDeadline: PAST }), + findOne: async () => ({ + id: "booking-1", + reference: "BK-001", + paymentDeadline: PAST, + }), }), + query: async () => [{ phone: "+251900000000", email: "c@x.com" }], }; + const directSend = jest.fn().mockResolvedValue(undefined); + const notify = jest.fn().mockResolvedValue(undefined); const service = new BillingService( dataSource as never, { findById: async () => invoice } as never, @@ -1071,10 +1134,12 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => { { upload: async () => ({ id: "file-1", name: "slip.pdf" }) } as never, { get: () => undefined } as never, { isEnabled: async () => true } as never, + { directSend } as never, + { notify } as never, ); (service as unknown as { recordPayment: unknown }).recordPayment = recordPayment; - return { service, recordPayment }; + return { service, recordPayment, directSend, notify }; } const slip = { originalname: "slip.pdf" } as never; @@ -1097,6 +1162,42 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => { ); }); + it("notifies the customer (inbox + SMS + email) once the payment is confirmed", async () => { + const { service, notify, directSend } = makeService( + WAGON_CANCEL_FEE_INVOICE_TYPE, + ); + await service.confirmOfflinePayment("inv-1", slip, {}); + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ + recipients: { companyId: "company-1" }, + type: "PAYMENT_RECEIVED", + link: "/billing/inv-1", + body: expect.stringMatching(/500 ETB .*INV-001 \(booking BK-001\)/), + }), + ); + expect(directSend).toHaveBeenCalledWith( + "sms", + "+251900000000", + expect.stringContaining("INV-001"), + ); + expect(directSend).toHaveBeenCalledWith( + "email", + "c@x.com", + expect.stringContaining("INV-001"), + ); + }); + + it("still settles when the customer notice fails", async () => { + const { service, notify, recordPayment } = makeService( + WAGON_CANCEL_FEE_INVOICE_TYPE, + ); + notify.mockRejectedValueOnce(new Error("inbox down")); + await expect( + service.confirmOfflinePayment("inv-1", slip, {}), + ).resolves.toBeDefined(); + expect(recordPayment).toHaveBeenCalled(); + }); + it("still requires the bank slip for a cancellation fee", async () => { const { service } = makeService(WAGON_CANCEL_FEE_INVOICE_TYPE); await expect( 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..ce8c95f40 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1,4 +1,9 @@ -import { Freight, PaymentReferenceType } from "@edr/types"; +import { + Freight, + NotificationAudience, + NotificationType, + PaymentReferenceType, +} from "@edr/types"; import { ConfigService } from "@nestjs/config"; import { BadRequestException, @@ -20,6 +25,10 @@ import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "../bookings/entities/booking-wago import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity"; import { ShippingLineCredit } from "../shipping-lines/entities/shipping-line-credit.entity"; import { ManualPaymentSettingsService } from "../payment-settings/manual-payment-settings.service"; +import { NotificationInboxService } from "../notification-inbox/notification-inbox.service"; +import { NotificationsService } from "../notifications/notifications.service"; +import { sendCompanyChannels } from "../notifications/notify-company.util"; +import { resolveShippingLineNotifyTarget } from "../notifications/resolve-shipping-line-contact.util"; import { EimsConfig } from "../../config/eims.config"; import { CompaniesService } from "../companies/companies.service"; import { EimsInvoiceStatus } from "../eims/eims-registration.types"; @@ -29,9 +38,12 @@ import { PaymentService } from "../payment/payment.service"; import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto"; import { InvoiceDocumentModel, + sameCompanyName, InvoiceDocumentService, pngDataUrl, } from "./documents/invoice-document.service"; +import { amountInWords } from "./documents/mor-document.util"; +import { buildEimsSeller, resolveLineTax } from "../eims/eims-invoice-context"; import { INVOICE_SORT_COLUMNS } from "./dto/filter-invoice.dto"; import { InvoiceLine } from "./entities/invoice-line.entity"; import { Invoice, InvoicePayment } from "./entities/invoice.entity"; @@ -41,6 +53,7 @@ import { applySettlement, invoicePaymentMethodExpr, round2, + settlementReferences, } from "./invoice-settlement.util"; import { InvoiceRepository } from "./invoice.repository"; @@ -114,6 +127,8 @@ export interface InvoiceListFilters { status?: Freight.InvoiceStatus; statuses?: Freight.InvoiceStatus[]; sources?: string[]; + /** What the invoice bills for (`PREPAID`, `DEMURRAGE`, …) — free-form per source. */ + types?: string[]; eimsStatuses?: string[]; /** Settled payment method, normalised UPPER_SNAKE — see `invoicePaymentMethodExpr`. */ paymentMethods?: string[]; @@ -267,6 +282,8 @@ export class BillingService { private readonly files: FilesService, private readonly config: ConfigService, private readonly manualPaymentSettings: ManualPaymentSettingsService, + private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, ) { } // ── Reads ────────────────────────────────────────────────────────────────── @@ -300,7 +317,12 @@ 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.types?.length) { + qb.andWhere("invoice.type IN (:...types)", { types: filter.types }); } if (filter.eimsStatuses?.length) { qb.andWhere("invoice.eimsStatus IN (:...eimsStatuses)", { @@ -326,7 +348,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 +613,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 +643,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 +775,7 @@ export class BillingService { } as OfflineUsdInvoiceRow; }), total, + outstanding, }; } @@ -769,8 +843,9 @@ export class BillingService { uploadedByName: input.userName ?? null, }); - return this.recordPayment(invoiceId, { - amount: Number(invoice.balanceAmount), + const amount = Number(invoice.balanceAmount); + const paid = await this.recordPayment(invoiceId, { + amount, method: "BANK_TRANSFER", reference: input.reference || slip.name, metadata: { @@ -780,6 +855,104 @@ export class BillingService { confirmedByName: input.userName ?? null, }, }); + + // The customer did not pay through the portal, so nothing else tells them + // Finance has settled their invoice — this is their only confirmation. + await this.notifyCustomerManualPaymentConfirmed(paid, amount); + return paid; + } + + /** + * Tell the customer Finance confirmed their manual (bank transfer / counter) + * payment: portal inbox entry plus SMS and email to the company's contact + * (or the shipping line's own contact for a credit invoice). Best-effort — + * a notification failure never undoes the settlement, it is only logged. + */ + private async notifyCustomerManualPaymentConfirmed( + invoice: Invoice, + amount: number, + ): Promise { + try { + const bookingRef = + invoice.source === Freight.InvoiceSource.Booking + ? await this.bookingReferenceFor(invoice.sourceId) + : null; + const body = + `Your payment of ${round2(amount)} ${invoice.currency} for invoice ${invoice.invoiceNumber}` + + (bookingRef ? ` (booking ${bookingRef})` : "") + + ` has been received and confirmed. Thank you.`; + const title = "Payment confirmed"; + const data = { + invoiceId: invoice.id, + invoiceNumber: invoice.invoiceNumber, + bookingId: bookingRef ? invoice.sourceId : null, + }; + + if (invoice.companyId || invoice.companyProfileId) { + await this.inbox.notify({ + recipients: invoice.companyId + ? { companyId: invoice.companyId } + : { companyProfileId: invoice.companyProfileId! }, + audience: NotificationAudience.PORTAL, + type: NotificationType.PAYMENT_RECEIVED, + title, + body, + link: `/billing/${invoice.id}`, + data, + }); + if (invoice.companyId) { + await sendCompanyChannels( + this.dataSource, + this.notifications, + invoice.companyId, + body, + ); + } + return; + } + + if (invoice.shippingLineCompanyId) { + const target = await resolveShippingLineNotifyTarget( + this.dataSource, + invoice.shippingLineCompanyId, + ); + if (target.userId) { + await this.inbox.notify({ + recipients: { userIds: [target.userId] }, + audience: NotificationAudience.PORTAL, + type: NotificationType.PAYMENT_RECEIVED, + title, + body, + link: `/shipping-line/invoices/${invoice.id}`, + data, + }); + } + for (const [method, to] of [ + ["sms", target.phone], + ["email", target.email], + ] as const) { + if (!to) continue; + try { + await this.notifications.directSend(method, to, body); + } catch { + /* best-effort: provider unavailable */ + } + } + } + } catch (err) { + this.logger.warn( + `Manual payment confirmed notify failed for invoice ${invoice.id}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + /** Booking reference for a booking id, or null when the booking is gone. */ + private async bookingReferenceFor(bookingId: string): Promise { + const booking = await this.dataSource.getRepository(Booking).findOne({ + where: { id: bookingId }, + select: ["id", "reference"], + }); + return booking?.reference ?? null; } /** Invoice header plus its line items. */ @@ -848,7 +1021,9 @@ export class BillingService { { label: "Wagons", value: - booking.wagonsRequired != null ? String(booking.wagonsRequired) : null, + booking.wagonsRequired != null + ? String(booking.wagonsRequired) + : null, }, ]; } @@ -875,10 +1050,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 +1093,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 +1120,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 { @@ -933,16 +1133,133 @@ export class BillingService { currency: invoice.currency, summary, categoryHeader: "Charge type", - lines: invoice.lines.map((l) => ({ - description: l.description ?? l.chargeType, - category: l.chargeType, - quantity: l.quantity, - unitRate: l.unitRate, - amount: l.amount, - currency: l.currency, - })), + lines: invoice.lines.map((l) => { + // Same resolver the filing used, so the printed Tax Code / Excise / Discount columns + // state what MoR actually holds for this line. + const tax = eimsCfg?.invoice ? resolveLineTax(eimsCfg, l.chargeType) : null; + return { + description: l.description ?? l.chargeType, + category: l.chargeType, + quantity: l.quantity, + unitRate: l.unitRate, + amount: l.amount, + currency: l.currency, + nature: eimsCfg?.invoice?.natureOfSupplies ?? null, + uom: eimsCfg?.invoice?.unitDefault ?? null, + taxCode: tax?.code ?? null, + excise: tax?.exciseTaxValue ?? null, + discount: tax?.discount ?? null, + }; + }), totals, - qrImageUrl: invoice.eimsSignedQr ? pngDataUrl(invoice.eimsSignedQr) : null, + qrImageUrl: invoice.eimsSignedQr + ? pngDataUrl(invoice.eimsSignedQr) + : null, + mor: eimsCfg?.invoice ? this.buildMorDetails(invoice, eimsCfg) : null, + }; + } + + /** + * The MoR tax-document view of an invoice (ADD-P001) — the bilingual layout a customer also sees + * when they scan the QR on the Ministry's portal. + * + * Built from the invoice plus EIMS configuration alone, never from a live EIMS call: a document + * has to print whether or not it is registered yet, and printing must not depend on the gateway + * being up. Per-line tax comes from `resolveLineTax`, the same resolver that decided what was + * actually filed, so the paper and the filing cannot disagree. + */ + private buildMorDetails( + invoice: Invoice & { lines: InvoiceLine[] }, + cfg: EimsConfig, + ): InvoiceDocumentModel["mor"] { + const seller = buildEimsSeller(cfg); + const company = invoice.company; + const documentType = (invoice.eimsDocumentType as "INV" | "DEB" | "CRE" | undefined) ?? "INV"; + // CREDIT until the money is in: the title states the sale's payment nature, not its status. + const isCash = Number(invoice.paidAmount) >= Number(invoice.totalAmount); + + const TITLES: Record = { + INV: isCash + ? { am: "የእጅ በእጅ ሽያጭ ደረሰኝ / ተ.እ.ታ / ኤክሳይዝ ታክስ", en: "Cash sales invoice / VAT / Excise Tax" } + : { am: "የዱቤ ሽያጭ ደረሰኝ / ተ.እ.ታ / ኤክሳይዝ ታክስ", en: "Credit sales invoice / VAT / Excise Tax" }, + CRE: { am: "የታክስ ክሬዲት ሰነድ", en: "Tax Credit Note" }, + DEB: { am: "የታክስ ዴቢት ሰነድ", en: "Tax Debit Note" }, + }; + + let total = 0; + let excise = 0; + let discount = 0; + let vatAmount = 0; + let vatTaxable = 0; + for (const line of invoice.lines) { + const tax = resolveLineTax(cfg, line.chargeType); + const lineTotal = Number(line.amount); + total += lineTotal; + excise += tax.exciseTaxValue; + discount += tax.discount; + if (tax.ratePercent > 0) { + vatTaxable += lineTotal; + vatAmount += (lineTotal * tax.ratePercent) / 100; + } + } + const totalIncludingTax = Number(invoice.totalAmount); + const rate = cfg.invoice.taxRatePercent ?? 0; + + const title = TITLES[documentType] ?? TITLES.INV; + + return { + titleAm: title.am, + titleEn: title.en, + saleType: cfg.invoice.transactionType, + irn: invoice.eimsIrn, + systemNumber: cfg.systemNumber || null, + referenceNumber: invoice.eimsDocumentNumber ?? null, + relatedDocumentIrn: invoice.relatedInvoice?.eimsIrn ?? null, + seller: { + name: cfg.invoice.sellerLegalName || seller.LegalName, + city: seller.City, + subCity: seller.SubCity, + woreda: seller.Wereda, + kebele: seller.Locality, + houseNo: seller.HouseNumber, + tin: seller.Tin, + vatNumber: seller.VatNumber, + }, + buyer: { + name: company?.name ?? "N/A", + city: company?.zone ?? null, + subCity: company?.zone ?? null, + woreda: company?.woreda ?? null, + kebele: company?.kebele ?? null, + houseNo: company?.houseNo ?? null, + tin: company?.tin ?? null, + vatNumber: company?.vatNumber ?? null, + }, + tax: { + total: round2(total), + discount: round2(discount), + taxableTotal: round2(vatTaxable), + excise: round2(excise), + vatTaxableAmount: round2(vatTaxable), + // An exempt seller still prints the row, labelled the way the Ministry's portal labels it. + vatLabel: rate > 0 ? `ተ.እ.ታ / VAT ${rate}%` : `${cfg.invoice.taxCode} ታክስ / ${cfg.invoice.taxCode} Tax rate (N/A%)`, + vatAmount: round2(vatAmount), + incomeWithholding: cfg.invoice.incomeWithholdValue ?? 0, + vatWithholding: cfg.invoice.transactionWithholdValue ?? 0, + totalIncludingTax: round2(totalIncludingTax), + amountInWords: amountInWords(totalIncludingTax), + }, + payment: { + mode: isCash ? "CASH" : "CREDIT", + typeMethod: cfg.invoice.paymentTerm, + receiverName: company?.name ?? null, + }, + // A memo is an amendment to a filed document; MoR's layout carries the sign-off that + // authorised it. Names come from the recorded reason until an approval chain exists. + approval: + documentType === "INV" + ? null + : { requestedBy: invoice.eimsReason ?? null, checkedBy: null, approvedBy: null }, }; } @@ -1201,7 +1518,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 +1550,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 +1563,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 +1627,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 +2148,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 +2214,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 +2391,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..609518eef 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,203 @@ 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); + }); +}); + +describe("InvoiceDocumentService.buildHtml — MoR tax-document layout (ADD-P001)", () => { + const service = new InvoiceDocumentService({} as never, {} as never, {} as never); + + const mor = (over: Partial> = {}) => + ({ + titleAm: "የዱቤ ሽያጭ ደረሰኝ / ተ.እ.ታ / ኤክሳይዝ ታክስ", + titleEn: "Credit sales invoice / VAT / Excise Tax", + saleType: "B2B", + irn: "IRN-123", + systemNumber: "2B6E48BB75", + seller: { name: "Ethio-Djibouti Railway SC", tin: "0053481357" }, + buyer: { name: "Afri Software Solutions", tin: "0089238373" }, + tax: { + total: 904008.15, + discount: 0, + taxableTotal: 0, + excise: 0, + vatTaxableAmount: 0, + vatLabel: "VATEX ታክስ / VATEX Tax rate (N/A%)", + vatAmount: 0, + incomeWithholding: 0, + vatWithholding: 0, + totalIncludingTax: 904008.15, + amountInWords: "Nine hundred and four thousand and eight Birr and fifteen Cents", + }, + payment: { mode: "CREDIT", typeMethod: "IMMIDIATE", receiverName: "Afri Software Solutions" }, + ...over, + }) as NonNullable; + + it("switches layout only when the mor block is present", () => { + expect(service.buildHtml(model())).not.toContain("Total including Tax"); + expect(service.buildHtml(model({ mor: mor() }))).toContain("Total including Tax"); + }); + + it("prints the bilingual title, sale type, IRN and system number", () => { + const html = service.buildHtml(model({ mor: mor() })); + expect(html).toContain("Credit sales invoice / VAT / Excise Tax"); + expect(html).toContain("የዱቤ ሽያጭ ደረሰኝ"); + expect(html).toContain("(B2B)"); + expect(html).toContain("IRN-123"); + expect(html).toContain("2B6E48BB75"); + }); + + it("prints every totals row even when the figure is zero", () => { + const html = service.buildHtml(model({ mor: mor() })); + for (const label of [ + "Discount Amount", + "Taxable Total", + "Excise Tax", + "Total VAT Taxable Amount", + "Total Withheld Amount", + "Total VAT Withheld Amount", + "Total including Tax (in words)", + ]) { + expect(html).toContain(label); + } + }); + + it("renders amounts bare, with the currency named once in the total label", () => { + const html = service.buildHtml(model({ mor: mor() })); + expect(html).toContain("904,008.15"); + expect(html).toContain("Total (ETB)"); + // The generic "1 Birr (ETB)" per-cell format must not leak into the tax layout. + expect(html).not.toContain("904,008.15 Birr (ETB)"); + }); + + it("carries the MoR item columns", () => { + const html = service.buildHtml( + model({ + mor: mor(), + lines: [ + { + description: "Container Import", + quantity: 3, + unitRate: 5223, + amount: 15670, + nature: "service", + uom: "PCS", + taxCode: "VATEX", + excise: 0, + discount: 0, + }, + ], + }), + ); + expect(html).toContain("Tax Code"); + expect(html).toContain("VATEX"); + expect(html).toContain("service"); + expect(html).toContain("PCS"); + }); + + it("shows the related document and approval block on a credit/debit note", () => { + const html = service.buildHtml( + model({ + mor: mor({ + titleEn: "Tax Credit Note", + relatedDocumentIrn: "ORIGINAL-IRN", + approval: { requestedBy: "biruk", checkedBy: "ermias", approvedBy: "kassahun" }, + }), + }), + ); + expect(html).toContain("Related Document"); + expect(html).toContain("ORIGINAL-IRN"); + expect(html).toContain("INVOICE AMENDMENT AUTHORIZATION"); + expect(html).toContain("kassahun"); + }); + + it("renders the sales receipt's linked-invoice table", () => { + const html = service.buildHtml( + model({ + mor: mor({ + titleEn: "Cash Receipt Voucher", + receipt: { + rrn: "RRN-9", + reason: "Payment for goods purchased", + collectedAmount: 950, + invoices: [ + { + irn: "INV-IRN-1", + paymentCoverage: "PARTIAL", + totalAmount: 1200, + remainingAmount: 250, + paidAmount: 950, + }, + ], + }, + }), + }), + ); + expect(html).toContain("RRN-9"); + expect(html).toContain("Payment Coverage"); + expect(html).toContain("PARTIAL"); + expect(html).toContain("Remaining Amount"); + }); + + it("renders the withholding receipt without an item table", () => { + const html = service.buildHtml( + model({ + mor: mor({ + titleEn: "Withholding tax on payment", + tax: null, + withholding: { + receiptNumber: "WH-26-574705075", + counter: "574705075", + reason: "Tax Withholding", + type: "TWTH", + invoiceCurrency: "ETB", + preTaxAmount: 8640000, + withheldAmount: 259200, + systemType: "MAN", + systemNumber: "2B6E48BB75", + }, + }), + lines: [{ description: "ignored", amount: 1 }], + }), + ); + expect(html).toContain("WH-26-574705075"); + expect(html).toContain("TWTH"); + expect(html).toContain("Pre Tax Amount"); + expect(html).toContain("259,200.00"); + // A withholding receipt has no billed items — the item table must be suppressed entirely. + expect(html).not.toContain("Unit Price"); + }); +}); 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..ac5da6203 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 @@ -5,6 +5,11 @@ import { LogoSettingsService } from "../../logo-settings/logo-settings.service"; import { PdfRenderService } from "./pdf-render.service"; import { sealClass, sealImageCss, sealMarkup } from "./seal-markup.util"; import { logoImageCss, logoMarkup } from "./logo-markup.util"; +import { + formatDocumentTime, + formatEthiopianDate, + formatGregorianDate, +} from "./mor-document.util"; import { PdfColor, assembleSinglePagePdf, @@ -44,10 +49,53 @@ function money(amount: unknown, currency: string): string { return `${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`; } +/** + * Bare fixed-2 amount for the MoR tax layout — `1,304,228.00`, no currency suffix. + * + * The Ministry's own documents name the currency once, in the `ድምር (ETB) / Total (ETB)` label, and + * keep every figure a plain right-aligned number. Repeating "Birr (ETB)" in each cell (what the + * generic `money` helper does) both breaks that column alignment and reads as a different + * document from the one the customer sees when they scan the QR. + */ +function amount2(value: unknown): string { + return Number(value ?? 0).toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); +} + 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; @@ -57,6 +105,115 @@ export interface InvoiceDocumentLine { unitRate?: number | null; amount?: number | null; currency?: string | null; + /** + * MoR tax-document columns (ADD-P001). Populated only for documents that carry a + * {@link MorDocumentDetails}; the generic EDR layout ignores them. + */ + nature?: string | null; + uom?: string | null; + taxCode?: string | null; + excise?: number | null; + discount?: number | null; +} + +/** One party block (`ከ / From`, `ለ / To`) of a MoR tax document. */ +export interface MorPartyDetails { + name: string; + city?: string | null; + /** `ዞን / ክ/ከተማ` — Zone/Sub city. */ + subCity?: string | null; + woreda?: string | null; + kebele?: string | null; + houseNo?: string | null; + tin?: string | null; + subTin?: string | null; + vatNumber?: string | null; +} + +/** + * The Ministry's totals block, in its printed order. Every row prints even at zero — a tax + * document states each figure explicitly rather than omitting the ones that happen to be nil. + */ +export interface MorTaxSummary { + total: number; + discount: number; + taxableTotal: number; + excise: number; + vatTaxableAmount: number; + /** e.g. `ተ.እ.ታ / VAT 15%`, or `VATEX ታክስ / VATEX Tax rate (N/A%)` for an exempt seller. */ + vatLabel: string; + vatAmount: number; + incomeWithholding: number; + vatWithholding: number; + totalIncludingTax: number; + amountInWords: string; +} + +export interface MorPaymentDetails { + /** `CASH` / `CREDIT` — also selects the document title. */ + mode: string; + /** `IMMEDIATE` and friends. */ + typeMethod: string; + receiverName?: string | null; +} + +/** Credit/debit memo authorisation block. */ +export interface MorApprovalDetails { + requestedBy?: string | null; + checkedBy?: string | null; + approvedBy?: string | null; +} + +/** Sales receipt (CRV) specifics. */ +export interface MorReceiptDetails { + rrn: string; + reason: string; + collectedAmount: number; + invoices: Array<{ + irn: string; + paymentCoverage: string; + totalAmount: number; + remainingAmount: number; + paidAmount: number; + }>; +} + +/** Withholding receipt specifics — a different document shape, with no item table. */ +export interface MorWithholdingDetails { + receiptNumber: string; + counter: string; + reason: string; + /** MoR withholding type, e.g. `TWTH`. */ + type: string; + invoiceCurrency: string; + preTaxAmount: number; + withheldAmount: number; + systemType: string; + systemNumber: string; +} + +/** + * Everything the MoR (ADD-P001) print layout needs beyond the generic model. Present ⇒ the + * document renders in the Ministry's bilingual tax-document format instead of the plain EDR one. + */ +export interface MorDocumentDetails { + /** Bilingual heading, e.g. `የእጅ በእጅ ሽያጭ ደረሰኝ / ተ.እ.ታ / ኤክሳይዝ ታክስ` + `Cash sales invoice / VAT / Excise Tax`. */ + titleAm: string; + titleEn: string; + /** `B2B` / `B2C` / `B2G`. */ + saleType?: string | null; + irn?: string | null; + systemNumber?: string | null; + referenceNumber?: string | null; + /** Original document's IRN — credit and debit notes only. */ + relatedDocumentIrn?: string | null; + seller: MorPartyDetails; + buyer: MorPartyDetails; + tax?: MorTaxSummary | null; + payment?: MorPaymentDetails | null; + approval?: MorApprovalDetails | null; + receipt?: MorReceiptDetails | null; + withholding?: MorWithholdingDetails | null; } /** A labelled total row in the totals box; mark `grand` for the headline total. */ @@ -101,6 +258,12 @@ export interface InvoiceDocumentModel { * itself goes through the ordinary `summary` rows, not a dedicated field. */ qrImageUrl?: string | null; + /** + * Present ⇒ render the Ministry's bilingual tax-document layout (ADD-P001) rather than the + * generic EDR one. Set for every document EIMS knows about: invoice, credit/debit note, sales + * receipt and withholding receipt. + */ + mor?: MorDocumentDetails | null; } /** @@ -204,12 +367,38 @@ export class InvoiceDocumentService { }) .join(""); - const totalRows = model.totals - .map( - (total) => - `
${esc(total.label)}${esc(money(total.amount, model.currency))}
`, - ) - .join(""); + // A thermal receipt is a compact derivative of the A4 tax document, not a different document: + // the tax breakdown, the amount in words and the payment mode are the legally load-bearing + // parts and must survive the narrower page. Only the item-table columns are dropped. + const tax = model.mor?.tax; + const totalRows = tax + ? [ + ["Total", money(tax.total, model.currency)], + ["Discount", money(tax.discount, model.currency)], + ["Taxable Total", money(tax.taxableTotal, model.currency)], + ["Excise Tax", money(tax.excise, model.currency)], + [tax.vatLabel, money(tax.vatAmount, model.currency)], + ["Withheld", money(tax.incomeWithholding, model.currency)], + ["VAT Withheld", money(tax.vatWithholding, model.currency)], + ] + .map( + ([label, value]) => + `
${esc(label)}${esc(value)}
`, + ) + .join("") + + `
Total incl. Tax${esc(money(tax.totalIncludingTax, model.currency))}
` + + `
${esc(tax.amountInWords)}
` + : model.totals + .map( + (total) => + `
${esc(total.label)}${esc(money(total.amount, model.currency))}
`, + ) + .join(""); + + const payMarkup = model.mor?.payment + ? `
Mode of Payment${esc(model.mor.payment.mode)}
+
Type/Method${esc(model.mor.payment.typeMethod)}
` + : ""; const qrMarkup = model.qrImageUrl ? `
EIMS verification QR
Scan to verify (MoR EIMS)
` @@ -236,6 +425,7 @@ export class InvoiceDocumentService { .item-calc { text-align: right; font-family: monospace; font-size: 8.5px; } .total-row { display: flex; justify-content: space-between; font-size: 9px; padding: 2px 0; } .total-row.grand { font-size: 11px; font-weight: 800; border-top: 1px solid #0f172a; margin-top: 3px; padding-top: 4px; } + .words { font-size: 8px; text-align: center; margin-top: 4px; font-style: italic; } .qr { text-align: center; margin: 8px 0; } .qr img { width: 150px; height: 150px; } .qr-caption { font-size: 7px; color: #64748b; margin-top: 2px; } @@ -254,6 +444,7 @@ export class InvoiceDocumentService { ${itemBlocks}
${totalRows} + ${payMarkup} ${qrMarkup} @@ -309,7 +500,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)); @@ -382,6 +577,10 @@ export class InvoiceDocumentService { } buildHtml(model: InvoiceDocumentModel): string { + // A MoR-registered document prints in the Ministry's own bilingual format (ADD-P001). Anything + // else — internal fee notes, statements — keeps the plain EDR layout below. + if (model.mor) return this.buildMorHtml(model, model.mor); + const date = formatDate; const showCategory = Boolean(model.categoryHeader); const sealText = @@ -499,7 +698,317 @@ export class InvoiceDocumentService { `; } + /** + * MoR EIMS tax-document layout (ADD-P001) — invoice, credit/debit note, sales receipt and + * withholding receipt share this one template, differing only in which optional blocks appear. + * + * Field labels and their order come from the Ministry's own portal rendering of a registered EDR + * invoice, so a printout and the page a customer reaches by scanning the QR read the same way. + * Every totals row prints even at zero: a tax document states each figure rather than hiding the + * nil ones. + */ + buildMorHtml(model: InvoiceDocumentModel, mor: MorDocumentDetails): string { + const currency = model.currency; + const party = (p: MorPartyDetails, sideAm: string, sideEn: string, tinAm: string, tinEn: string): string => ` + + + + ${morRow("ከተማ", "City/Town", p.city)} + ${morRow("ዞን / ክ/ከተማ", "Zone/Sub city", p.subCity)} + ${morRow("ወረዳ", "Woreda", p.woreda)} + ${morRow("ቀበሌ", "Kebele", p.kebele)} + ${morRow("የቤ/ቁ", "H/No", p.houseNo)} + ${morRow("የግብር ከፋይ መለያ ቁጥር", `${tinEn}'s TIN`, p.tin, tinAm)} + ${morRow("ንዑስ/ቁ", "Sub-TIN", p.subTin)} + ${morRow("ተ.እ.ታ ቁጥር", `${tinEn}'s VAT`, p.vatNumber)} +
${esc(sideAm)}${esc(sideEn)}${esc(p.name)}
`; + + const itemRows = model.lines + .map( + (item, i) => ` + ${i + 1} + ${esc(item.description)} + ${esc(item.nature ?? "-")} + ${esc(item.uom ?? "-")} + ${esc(item.quantity ?? 0)} + ${esc(amount2(item.unitRate))} + ${esc(item.taxCode ?? "-")} + ${esc(amount2(item.excise ?? 0))} + ${esc(amount2(item.discount ?? 0))} + ${esc(amount2(item.amount))} + `, + ) + .join(""); + + const tax = mor.tax; + const taxRows = tax + ? [ + totalRow("ድምር", `Total (${currency})`, amount2(tax.total)), + totalRow("የቅናሽ መጠን", "Discount Amount", amount2(tax.discount)), + totalRow("ታክስ የሚከፈልበት ድምር", "Taxable Total", amount2(tax.taxableTotal)), + totalRow("ኤክሳይዝ ታክስ", "Excise Tax", amount2(tax.excise)), + totalRow("ተ.እ.ታ የሚከፈልበት ድምር", "Total VAT Taxable Amount", amount2(tax.vatTaxableAmount)), + totalRow("", tax.vatLabel, amount2(tax.vatAmount)), + totalRow("ጠቅላላ የተያዘ መጠን", "Total Withheld Amount", amount2(tax.incomeWithholding)), + totalRow("ጠቅላላ የተያዘ መጠን ተ.እ", "Total VAT Withheld Amount", amount2(tax.vatWithholding)), + totalRow("ጠቅላላ ዋጋ ከታክስ ጋር", "Total including Tax", amount2(tax.totalIncludingTax), true), + ].join("") + : ""; + + const wordsRow = tax + ? `ጠቅላላ ዋጋ ከታክስ ጋር (በፊደል)Total including Tax (in words) + ${esc(tax.amountInWords)}` + : ""; + + const receipt = mor.receipt; + const receiptBlock = receipt + ? ` + ${morRow("የክፍያ ምክንያት", "Payment Reason", receipt.reason)} + ${morRow("የተሰበሰበ መጠን", "Collected Amount", amount2(receipt.collectedAmount))} +
+
የደረሰኞች ዝርዝር / Invoices
+ + + + + + + + + ${receipt.invoices + .map( + (inv) => ` + + + + + + `, + ) + .join("")} +
IRN${esc("የክፍያ ሽፋን / Payment Coverage")}${esc("ጠቅላላ ዋጋ / Total Amount")}${esc("ቀሪ / Remaining Amount")}${esc("የተከፈለ / Paid Amount")}
${esc(inv.irn)}${esc(inv.paymentCoverage)}${esc(amount2(inv.totalAmount))}${esc(amount2(inv.remainingAmount))}${esc(amount2(inv.paidAmount))}
+ ` + : ""; + + const wh = mor.withholding; + const withholdingBlock = wh + ? ` + ${morRow("የደረሰኝ ቁጥር", "Receipt #", wh.receiptNumber)} + ${morRow("ቆጣሪ", "Counter", wh.counter)} + ${morRow("ምክንያት", "Reason", wh.reason)} + ${morRow("አይነት", "Type", wh.type)} +
+ + + + + + + + + + + + + +
${esc("የደረሰኝ ቁጥር / Invoice Doc. Number")}${esc("የገንዘብ ዓይነት / Invoice Currency")}${esc("ከታክስ በፊት ያለው ዋጋ / Pre Tax Amount")}${esc("ተይዞ የቀረ መጠን / Withheld Amount")}
${esc(wh.receiptNumber)}${esc(wh.invoiceCurrency)}${esc(amount2(wh.preTaxAmount))}${esc(amount2(wh.withheldAmount))}
+ + + ${morRow("የስርዓት አይነት", "System Type", wh.systemType)} + ${morRow("የስርዓት ቁጥር", "System Number", wh.systemNumber)} +
` + : ""; + + const payment = mor.payment; + const paymentBlock = payment + ? ` + + + + + +
የክፍያ ሁኔታMode of Payment${esc(payment.mode)}አይነትType/Method${esc(payment.typeMethod)}የተቀባይ ስምና ፊርማReceiver Name & Signature${esc(payment.receiverName ?? "")}
` + : ""; + + const approval = mor.approval; + const approvalBlock = approval + ? `
INVOICE AMENDMENT AUTHORIZATION
+
This amendment has been reviewed and approved in accordance with the company's approval matrix.
+ + + + + + +
የጠየቀውRequested By${esc(approval.requestedBy ?? "")}ያረጋገጠውChecked By${esc(approval.checkedBy ?? "")}ያፀደቀውApproved By${esc(approval.approvedBy ?? "")}
` + : ""; + + const qrBlock = model.qrImageUrl + ? `EIMS verification QR` + : ""; + + return ` + + + + ${esc(mor.titleEn)} ${esc(model.documentNumber)} + + + +
+
+
+ ${model.logoImageUrl ? `` : ""} +
${esc(mor.seller.name)}
+
Ethio-Djibouti Railway S.C.
+
+
+
የደረሰኝ ቁጥርDocument No${esc(model.documentNumber)}
+
ቀንDate${esc(formatEthiopianDate(model.issuedAt))}
+
${esc(formatGregorianDate(model.issuedAt))}
+
ሰአትTime${esc(formatDocumentTime(model.issuedAt))}
+
+
+ +
+
${esc(mor.titleAm)}
+
${esc(mor.titleEn)}
+ ${mor.saleType ? `
የሽያጭ አይነት (${esc(mor.saleType)})
` : ""} +
+ +
+ + ${mor.irn ? `` : ""} + ${mor.receipt ? `` : ""} + ${mor.systemNumber ? `` : ""} + ${mor.referenceNumber ? `` : ""} + ${mor.relatedDocumentIrn ? `` : ""} +
IRN${esc(mor.irn)}
RRN${esc(mor.receipt.rrn)}
System Number${esc(mor.systemNumber)}
Reference Number${esc(mor.referenceNumber)}
Related Document${esc(mor.relatedDocumentIrn)}
+ ${qrBlock} +
+ +
+
${party(mor.seller, "ከ", "From", "የሻጭ", "Seller")}
+
${party(mor.buyer, "ለ", "To", "የገዢ", "Customer")}
+
+ + ${withholdingBlock} + ${receiptBlock} + + ${ + model.lines.length > 0 && !mor.withholding + ? ` + + + + + + + + + + + + + + + ${itemRows} +
${esc("ተ/ቁ")}
No.
${esc("የዕቃው / አገልግሎት አይነት")}
Description
${esc("ምድብ")}
Nature
${esc("መለኪያ")}
UoM
${esc("ብዛት")}
Qty
${esc("የአንዱ ዋጋ")}
Unit Price
${esc("ታክስ ኮድ")}
Tax Code
${esc("ኤክሳይዝ")}
Excise
${esc("ቅናሽ")}
Discount
${esc("ጠቅላላ ዋጋ")}
Total Amount
` + : "" + } + + ${tax ? `${taxRows}${wordsRow}
` : ""} + ${paymentBlock} + ${approvalBlock} + +
+
Ethio-Djibouti Railway S.C. — ${esc(mor.titleEn)}
+
Page 1 of 1  ·  Printed ${esc(formatGregorianDate(new Date()))} ${esc(formatDocumentTime(new Date()))}
+
+
+ +`; + } + safeFilename(value: string): string { return value.replace(/[^a-zA-Z0-9_-]+/g, "-"); } } + +/** One bilingual label/value row inside a party or key-value table. */ +function morRow(am: string, en: string, value: unknown, amOverride?: string): string { + return `${esc(amOverride ? `${amOverride} ${am}` : am)}${esc(en)}${esc( + value === null || value === undefined || value === "" ? "N/A" : value, + )}`; +} + +/** One row of the Ministry's totals block. */ +function totalRow(am: string, en: string, value: string, grand = false): string { + return `${esc(am ? `${am} / ${en}` : en)}${esc(value)}`; +} diff --git a/apps/edr-freight-api/src/modules/billing/documents/mor-document.util.spec.ts b/apps/edr-freight-api/src/modules/billing/documents/mor-document.util.spec.ts new file mode 100644 index 000000000..ab9ad6791 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/mor-document.util.spec.ts @@ -0,0 +1,65 @@ +import { + amountInWords, + formatEthiopianDate, + formatGregorianDate, + gregorianToEthiopian, + numberToWords, +} from "./mor-document.util"; + +describe("gregorianToEthiopian", () => { + it("matches the MoR portal's own rendering of a registered EDR invoice", () => { + // portal.mor.gov.et printed `25-12-2018 ዓ/ም` beside `31-08-2026 G.C` for INV document no. 3. + expect(gregorianToEthiopian(new Date(2026, 7, 31))).toEqual({ year: 2018, month: 12, day: 25 }); + expect(formatEthiopianDate(new Date(2026, 7, 31))).toBe("25-12-2018 ዓ/ም"); + expect(formatGregorianDate(new Date(2026, 7, 31))).toBe("31-08-2026 G.C"); + }); + + it("rolls the year on Ethiopian new year, not on the Gregorian one", () => { + // 11 Sep 2026 is 1 መስከረም 2019; the day before is still 2018. + expect(gregorianToEthiopian(new Date(2026, 8, 10))).toMatchObject({ year: 2018, month: 13 }); + expect(gregorianToEthiopian(new Date(2026, 8, 11))).toEqual({ year: 2019, month: 1, day: 1 }); + }); + + it("returns a placeholder rather than throwing on a missing date", () => { + expect(formatEthiopianDate(null)).toBe("-"); + expect(formatGregorianDate(undefined)).toBe("-"); + }); +}); + +describe("amountInWords", () => { + it("spells an amount with cents the way the reference tax invoice does", () => { + // WISCOM's certified printout: 3,759.93 -> "three thousand seven hundred and fifty-nine Birr + // and ninety-three Cents". + expect(amountInWords(3759.93)).toBe( + "Three thousand seven hundred and fifty-nine Birr and ninety-three Cents", + ); + }); + + it("keeps the 'and' inside a scale group, as the reference printouts do", () => { + // 407,422.98 on the reference credit-sales invoice reads "Four Hundred And Seven Thousand Four + // Hundred And Twenty-Two Birr and Ninety-Eight Cents". Note the MoR portal itself uses the + // other convention ("nine hundred four thousand"); the printed document follows the reference. + expect(amountInWords(407422.98)).toBe( + "Four hundred and seven thousand four hundred and twenty-two Birr and ninety-eight Cents", + ); + }); + + it("omits the cents clause on a whole amount", () => { + expect(amountInWords(880)).toBe("Eight hundred and eighty Birr"); + }); + + it("carries rounded cents into the Birr instead of printing 100 Cents", () => { + expect(amountInWords(9.999)).toBe("Ten Birr"); + }); + + it("handles zero and sub-Birr amounts", () => { + expect(amountInWords(0)).toBe("Zero Birr"); + expect(amountInWords(0.5)).toBe("Zero Birr and fifty Cents"); + }); + + it("spells the scale words", () => { + expect(numberToWords(1_000_000)).toBe("one million"); + expect(numberToWords(21)).toBe("twenty-one"); + expect(numberToWords(115)).toBe("one hundred and fifteen"); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/documents/mor-document.util.ts b/apps/edr-freight-api/src/modules/billing/documents/mor-document.util.ts new file mode 100644 index 000000000..ba74737b1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/mor-document.util.ts @@ -0,0 +1,178 @@ +/** + * Presentation helpers for MoR EIMS tax documents (ADD-P001 print layout). + * + * The layout these serve is modelled on the Ministry's own portal rendering of a registered EDR + * invoice (portal.mor.gov.et), which is the authoritative source for the bilingual field labels — + * not on any one vendor's template. + */ + +/** Ethiopian month names, index 0 = መስከረም. */ +const ETHIOPIAN_MONTHS = [ + "መስከረም", + "ጥቅምት", + "ኅዳር", + "ታኅሣሥ", + "ጥር", + "የካቲት", + "መጋቢት", + "ሚያዝያ", + "ግንቦት", + "ሰኔ", + "ሐምሌ", + "ነሐሴ", + "ጳጉሜ", +] as const; + +export interface EthiopianDate { + year: number; + month: number; + day: number; +} + +/** + * Gregorian → Ethiopian, via Julian Day Number. + * + * JDN rather than day-of-year arithmetic because the Ethiopian new year drifts against September + * 11/12 on the Gregorian leap cycle; JDN is the same conversion the passenger portal already uses. + */ +export function gregorianToEthiopian(date: Date): EthiopianDate { + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + const a = Math.floor((14 - month) / 12); + const y = year + 4800 - a; + const m = month + 12 * a - 3; + const jdn = + day + + Math.floor((153 * m + 2) / 5) + + 365 * y + + Math.floor(y / 4) - + Math.floor(y / 100) + + Math.floor(y / 400) - + 32045; + + // 1723856 is the JDN of 1 መስከረም 1 E.C. + const r = (jdn - 1723856) % 1461; + const n = (r % 365) + 365 * Math.floor(r / 1460); + const ethYear = 4 * Math.floor((jdn - 1723856) / 1461) + Math.floor(r / 365) - Math.floor(r / 1460); + const ethMonth = Math.floor(n / 30) + 1; + const ethDay = (n % 30) + 1; + + return { year: ethYear, month: ethMonth, day: ethDay }; +} + +/** `25-12-2018 ዓ/ም` — the numeric form the MoR portal prints beside the Gregorian date. */ +export function formatEthiopianDate(value: Date | string | null | undefined): string { + const date = value ? new Date(value) : null; + if (!date || Number.isNaN(date.getTime())) return "-"; + const { year, month, day } = gregorianToEthiopian(date); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${pad(day)}-${pad(month)}-${year} ዓ/ም`; +} + +/** `ሐምሌ 25, 2018` — the long form, when a document has room for it. */ +export function formatEthiopianDateLong(value: Date | string | null | undefined): string { + const date = value ? new Date(value) : null; + if (!date || Number.isNaN(date.getTime())) return "-"; + const { year, month, day } = gregorianToEthiopian(date); + return `${ETHIOPIAN_MONTHS[month - 1] ?? ""} ${day}, ${year}`; +} + +/** `31-08-2026 G.C` — Gregorian, labelled the way the MoR portal labels it. */ +export function formatGregorianDate(value: Date | string | null | undefined): string { + const date = value ? new Date(value) : null; + if (!date || Number.isNaN(date.getTime())) return "-"; + const pad = (n: number) => String(n).padStart(2, "0"); + return `${pad(date.getDate())}-${pad(date.getMonth() + 1)}-${date.getFullYear()} G.C`; +} + +/** `10:58:30`, 24-hour, to match the portal's `ሰአት/Time` row. */ +export function formatDocumentTime(value: Date | string | null | undefined): string { + const date = value ? new Date(value) : null; + if (!date || Number.isNaN(date.getTime())) return "-"; + const pad = (n: number) => String(n).padStart(2, "0"); + return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; +} + +const ONES = [ + "", + "one", + "two", + "three", + "four", + "five", + "six", + "seven", + "eight", + "nine", + "ten", + "eleven", + "twelve", + "thirteen", + "fourteen", + "fifteen", + "sixteen", + "seventeen", + "eighteen", + "nineteen", +]; +const TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"]; +const SCALES: [number, string][] = [ + [1_000_000_000, "billion"], + [1_000_000, "million"], + [1_000, "thousand"], +]; + +/** 0-999 in words. */ +function underThousand(value: number): string { + if (value < 20) return ONES[value]; + if (value < 100) { + const rest = value % 10; + return TENS[Math.floor(value / 10)] + (rest ? `-${ONES[rest]}` : ""); + } + const rest = value % 100; + return `${ONES[Math.floor(value / 100)]} hundred${rest ? ` and ${underThousand(rest)}` : ""}`; +} + +/** Whole number in words. Returns "zero" for 0. */ +export function numberToWords(value: number): string { + const n = Math.floor(Math.abs(value)); + if (n === 0) return "zero"; + + const parts: string[] = []; + let remaining = n; + for (const [scale, name] of SCALES) { + const count = Math.floor(remaining / scale); + if (count > 0) { + parts.push(`${numberToWords(count)} ${name}`); + remaining %= scale; + } + } + if (remaining > 0) { + // "and" only before a trailing sub-hundred group, matching how the amount reads aloud + // ("three thousand seven hundred and fifty-nine", not "three thousand and seven hundred"). + parts.push(parts.length > 0 && remaining < 100 ? `and ${underThousand(remaining)}` : underThousand(remaining)); + } + return parts.join(" "); +} + +/** + * `Total including Tax (in words)` — the legally required spelling-out of the payable amount. + * + * Computed here rather than read back from MoR: the Ministry renders its own copy on the portal, + * but returns nothing carrying it on `/v1/register`, and the line has to print on a document that + * may not be registered yet. + */ +export function amountInWords(value: number, currencyLabel = "Birr", fractionLabel = "Cents"): string { + const amount = Number.isFinite(value) ? Math.abs(value) : 0; + const birr = Math.floor(amount); + // Round the remainder rather than truncate: 0.155 must read as sixteen cents, not fifteen. + const cents = Math.round((amount - birr) * 100); + // Rounding cents can carry into the next Birr (x.999 -> 100 cents). + const [wholeBirr, wholeCents] = cents === 100 ? [birr + 1, 0] : [birr, cents]; + + const head = `${numberToWords(wholeBirr)} ${currencyLabel}`; + const text = wholeCents > 0 ? `${head} and ${numberToWords(wholeCents)} ${fractionLabel}` : head; + return text.charAt(0).toUpperCase() + text.slice(1); +} diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts index 55e6b19d2..45c7acb51 100644 --- a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts @@ -22,6 +22,7 @@ describe("FilterInvoiceDto", () => { search: "INV-2026", statuses: "PENDING,OVERDUE", sources: "booking,warehouse", + types: "PREPAID,WAGON_CANCEL_FEE", eimsStatuses: "NOT_SUBMITTED", currency: "etb", issuedFrom: "2026-08-01T00:00:00.000Z", @@ -39,6 +40,7 @@ describe("FilterInvoiceDto", () => { expect(errors).toEqual([]); expect(dto.statuses).toEqual(["PENDING", "OVERDUE"]); expect(dto.sources).toEqual(["booking", "warehouse"]); + expect(dto.types).toEqual(["PREPAID", "WAGON_CANCEL_FEE"]); expect(dto.currency).toBe("ETB"); expect(dto.minAmount).toBe(100); expect(dto.hasBalance).toBe(true); diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts index a98ad06c1..fa00fb521 100644 --- a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts @@ -89,6 +89,18 @@ export class FilterInvoiceDto { @IsIn(Object.values(Freight.InvoiceSource), { each: true }) sources?: Freight.InvoiceSource[]; + /** + * What the invoice bills for (`?types=PREPAID,WAGON_CANCEL_FEE`). Free-form + * like `paymentMethods`: every billing source mints its own `type` string, so + * an `IsIn` here would silently drop a real value. + */ + @ApiPropertyOptional({ isArray: true, example: ["PREPAID"] }) + @IsOptional() + @Transform(csv) + @IsArray() + @IsString({ each: true }) + types?: string[]; + /** MoR filing state — Finance's "what still needs registering" cut. */ @ApiPropertyOptional({ isArray: true, enum: EimsInvoiceStatus }) @IsOptional() 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-content.sql.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-content.sql.spec.ts new file mode 100644 index 000000000..0668b9eba --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-content.sql.spec.ts @@ -0,0 +1,146 @@ +import { + CARGO_TYPE_SUBTREE_SQL, + bookingContainerCountSql, + bookingContainerVgmSql, + bookingContentMatchSql, + bookingContentSql, + bookingHasContainerTypeSql, + bookingRequestedCargoSql, + bookingRequestedContainerCountSql, +} from './booking-content.sql'; + +describe('bookingContentSql', () => { + const sql = bookingContentSql('b'); + + it('prefers the container lines, since container bookings carry no description', () => { + expect(sql.indexOf('freight.booking_container')).toBeLessThan( + sql.indexOf('freight.cargo_types'), + ); + expect(sql).toContain('freight.container_types'); + expect(sql).toContain('bc.deleted_at IS NULL'); + }); + + it('falls back to commodity, then to the free-text description', () => { + expect(sql.indexOf('cgt.cargo_type_name')).toBeLessThan( + sql.indexOf('b.cargo_free_text'), + ); + }); + + // An empty string is not a missing value to COALESCE — without NULLIF a blank + // description would win over the commodity behind it. + it('treats an empty string as absent at every level', () => { + expect(sql.match(/NULLIF/g)).toHaveLength(3); + }); + + it('rewrites every reference when embedded under another alias', () => { + expect(bookingContentSql('bk')).not.toMatch(/\bb\.(cargo|id)/); + }); +}); + +describe('CARGO_TYPE_SUBTREE_SQL', () => { + // The filter offers groups, not just leaves, so picking "Bulk" has to reach + // commodities at any depth beneath it — two levels today, more tomorrow. + it('walks the tree recursively rather than one level of children', () => { + expect(CARGO_TYPE_SUBTREE_SQL).toContain('WITH RECURSIVE'); + expect(CARGO_TYPE_SUBTREE_SQL).toContain('c.parent_group_id = sub.id'); + }); + + it('includes the picked node itself, so a leaf still matches exactly', () => { + expect(CARGO_TYPE_SUBTREE_SQL).toContain('WHERE id = :cargoTypeId'); + }); +}); + +describe('bookingContentMatchSql', () => { + const sql = bookingContentMatchSql('b'); + + it('searches all three places content can live', () => { + expect(sql).toContain('b.cargo_free_text ILIKE :cargoText'); + expect(sql).toContain('cgt.cargo_type_name ILIKE :cargoText'); + expect(sql).toContain('cnt.code ILIKE :cargoText'); + }); + + // Anything but OR would make the text box match nothing for whole freight + // types — a container booking has no commodity, a bulk one has no container. + it('ORs them, and stays one parenthesised term for andWhere', () => { + expect(sql).not.toContain(' AND :cargoText'); + expect(sql.startsWith('(')).toBe(true); + expect(sql.trimEnd().endsWith(')')).toBe(true); + }); +}); + +describe('bookingContainerCountSql', () => { + // booking_container is one row per LINE carrying a quantity, so counting rows + // would report a 54-container booking as 1. + it('sums the line quantities rather than counting lines', () => { + expect(bookingContainerCountSql('b')).toContain('SUM(bc.quantity)'); + expect(bookingContainerCountSql('b')).not.toContain('COUNT('); + }); + + it('counts every type by default and one type when scoped', () => { + expect(bookingContainerCountSql('b')).not.toContain('container_type_id'); + expect(bookingContainerCountSql('b', true)).toContain( + 'bc.container_type_id = :containerTypeId', + ); + }); + + it('is 0, never NULL, so a bound comparison still decides', () => { + expect(bookingContainerCountSql('b')).toContain('COALESCE(SUM(bc.quantity), 0)'); + }); + + it('ignores soft-deleted lines', () => { + expect(bookingContainerCountSql('b')).toContain('bc.deleted_at IS NULL'); + expect(bookingHasContainerTypeSql('b')).toContain('bc.deleted_at IS NULL'); + }); + + it('rewrites the booking reference under another alias', () => { + expect(bookingContainerCountSql('bk')).toContain('bc.booking_id = bk.id'); + expect(bookingHasContainerTypeSql('bk')).toContain('bc.booking_id = bk.id'); + }); +}); + +describe('bookingContainerVgmSql', () => { + // The whole point: b.cargo_total_weight_vgm is 0 for portal container + // bookings, so the weight has to come off the lines. + it('reads the lines, never the booking-level column', () => { + const sql = bookingContainerVgmSql('b'); + expect(sql).toContain('SUM(bc.total_vgm_tons)'); + expect(sql).not.toContain('cargo_total_weight_vgm'); + expect(sql).toContain('bc.deleted_at IS NULL'); + }); +}); + +describe('requested (shipment-request) cargo', () => { + const cargo = bookingRequestedCargoSql('b'); + const count = bookingRequestedContainerCountSql('b'); + + it('reads the request, never the booking or its container lines', () => { + for (const sql of [cargo, count]) { + expect(sql).toContain('freight.booking_requests br'); + expect(sql).toContain('br.created_booking_id = b.id'); + expect(sql).not.toContain('freight.booking_container'); + } + }); + + // requested_lines is a free-form jsonb column; jsonb_array_elements throws on + // a non-array, which would 500 the whole list for one malformed row. + it('survives a requested_lines with no container array', () => { + for (const sql of [cargo, count]) { + expect(sql).toContain("jsonb_typeof(br.requested_lines->'containers') = 'array'"); + expect(sql).toContain("ELSE '[]'::jsonb"); + } + }); + + it('renders the bulk shape too, not only containers', () => { + expect(cargo).toContain("'bulk'->>'cargoWeightTons'"); + expect(cargo).toContain("'bulk'->>'itemCount'"); + }); + + it('counts 0 rather than NULL when no request exists', () => { + expect(count).toContain("COALESCE(SUM((l->>'quantity')::int), 0)"); + }); + + it('ignores soft-deleted requests', () => { + expect(cargo).toContain('br.deleted_at IS NULL'); + expect(count).toContain('br.deleted_at IS NULL'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts b/apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts new file mode 100644 index 000000000..744a6bbd8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts @@ -0,0 +1,148 @@ +/** + * What the customer said is IN the booking, per freight type — the list + * filter, the summary and the export all read this one expression so the + * column, the pill and the sheet can never disagree. + * + * BULK the commodity picked from the cargo tree (`cargo_types`), falling + * back to the free-text description for a bare group or a legacy row + * that has no commodity. + * CONTAINER the wizard asks for no description at all — VGM and contents are + * captured later in operations — so the closest thing to the + * customer's own words is the container lines they entered: + * "2 × 40FT, 1 × 20FT". + * + * Containers are checked FIRST: a container booking has no `cargo_type_id` + * (the API rejects one), so the order only matters for a mixed legacy row, + * where the physical lines are the better answer. + */ +export function bookingContentSql(alias = 'b'): string { + return `COALESCE( + NULLIF((SELECT string_agg(bc.quantity || ' × ' || COALESCE(cnt.label, cnt.code), ', ' + ORDER BY cnt.size_ft DESC NULLS LAST, cnt.code) + FROM freight.booking_container bc + JOIN freight.container_types cnt ON cnt.id = bc.container_type_id + WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL), ''), + NULLIF((SELECT cgt.cargo_type_name FROM freight.cargo_types cgt + WHERE cgt.id = ${alias}.cargo_type_id), ''), + NULLIF(${alias}.cargo_free_text, ''))`; +} + +/** + * Cargo types at or under `:cargoTypeId`, so picking a GROUP in the filter + * matches every commodity beneath it — the same group→commodity drill-down the + * booking wizard offers, read back. Recursive because `cargo_types` is an + * arbitrary-depth tree (Bulk → Steel Billet → S1 → …), not two levels. + */ +export const CARGO_TYPE_SUBTREE_SQL = `( + WITH RECURSIVE sub AS ( + SELECT id FROM freight.cargo_types WHERE id = :cargoTypeId + UNION ALL + SELECT c.id FROM freight.cargo_types c JOIN sub ON c.parent_group_id = sub.id + ) + SELECT id FROM sub)`; + +/** + * Contains-match over every part of the content a customer can type or pick: + * their own description, the commodity's name, and the container types on the + * booking. Bind `:cargoText` already wrapped in `%`. + */ +export function bookingContentMatchSql(alias = 'b'): string { + return `(${alias}.cargo_free_text ILIKE :cargoText + OR EXISTS (SELECT 1 FROM freight.cargo_types cgt + WHERE cgt.id = ${alias}.cargo_type_id + AND cgt.cargo_type_name ILIKE :cargoText) + OR EXISTS (SELECT 1 FROM freight.booking_container bc + JOIN freight.container_types cnt ON cnt.id = bc.container_type_id + WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL + AND (cnt.label ILIKE :cargoText OR cnt.code ILIKE :cargoText)))`; +} + +/** + * Containers on a booking, as a count of physical boxes — `booking_container` + * is one row PER LINE with a `quantity`, not one row per box, so this sums the + * quantity rather than counting rows. + * + * `scopedToType` narrows the sum to `:containerTypeId`, which is what makes one + * number filter answer both "10 containers in total" and "10 forty-footers": + * the count filter reads the container-type filter when one is set, and counts + * every type when it is not. + */ +export function bookingContainerCountSql(alias = 'b', scopedToType = false): string { + return `(SELECT COALESCE(SUM(bc.quantity), 0) + FROM freight.booking_container bc + WHERE bc.booking_id = ${alias}.id + AND bc.deleted_at IS NULL${ + scopedToType ? '\n AND bc.container_type_id = :containerTypeId' : '' + })`; +} + +/** Bookings carrying at least one line of `:containerTypeId`. */ +export function bookingHasContainerTypeSql(alias = 'b'): string { + return `EXISTS (SELECT 1 FROM freight.booking_container bc + WHERE bc.booking_id = ${alias}.id + AND bc.deleted_at IS NULL + AND bc.container_type_id = :containerTypeId)`; +} + +/** + * Container VGM on a booking, in tons — the sum of the per-line totals. + * + * NOT `bookings.cargo_total_weight_vgm`: the portal wizard leaves that at 0 for + * container freight (VGM is captured per container, later, in operations), so + * reading the booking-level column showed every portal container booking as + * weighing nothing. Same reason `bookingTonsSql` falls through to these lines. + */ +export function bookingContainerVgmSql(alias = 'b'): string { + return `(SELECT COALESCE(SUM(bc.total_vgm_tons), 0) + FROM freight.booking_container bc + WHERE bc.booking_id = ${alias}.id + AND bc.deleted_at IS NULL)`; +} + +/** + * Cargo the customer declared on the SHIPMENT REQUEST behind a booking, which + * is not the same fact as cargo on the booking itself. + * + * On a GENERAL + customs contract the customer cannot book directly: they + * submit a request (day + quantities), and `initiateForShipmentRequest` opens a + * BARE instance from it — "the request itself carries the quantities; the + * instance carries none". So between initiation and `completeUnderContract` the + * booking legitimately holds no cargo while the customer's declared quantities + * sit on `booking_requests.requested_lines`. + * + * Kept in its own column rather than folded into the real container count: a + * declared 2 × 20FT is a request, not two boxes on a booking, and merging the + * two would overstate operational totals. + */ +const REQUESTED_CONTAINER_LINES = `jsonb_array_elements( + CASE WHEN jsonb_typeof(br.requested_lines->'containers') = 'array' + THEN br.requested_lines->'containers' + ELSE '[]'::jsonb END)`; + +/** Human-readable declared cargo: "2 × 20FT", "12 t", "40 items". */ +export function bookingRequestedCargoSql(alias = 'b'): string { + return `(SELECT COALESCE( + (SELECT string_agg((l->>'quantity') || ' × ' || upper(l->>'containerSize'), ', ' + ORDER BY l->>'containerSize') + FROM ${REQUESTED_CONTAINER_LINES} AS l), + NULLIF(br.requested_lines->'bulk'->>'cargoWeightTons', '') || ' t', + NULLIF(br.requested_lines->'bulk'->>'itemCount', '') || ' items') + FROM freight.booking_requests br + WHERE br.created_booking_id = ${alias}.id + AND br.deleted_at IS NULL + ORDER BY br.created_at DESC + LIMIT 1)`; +} + +/** + * Boxes declared on the shipment request. Pairs with the real container count: + * `Containers = 0` AND `Requested containers >= 1` is exactly the set awaiting + * completion. + */ +export function bookingRequestedContainerCountSql(alias = 'b'): string { + return `(SELECT COALESCE(SUM((l->>'quantity')::int), 0) + FROM freight.booking_requests br + CROSS JOIN LATERAL ${REQUESTED_CONTAINER_LINES} AS l + WHERE br.created_booking_id = ${alias}.id + AND br.deleted_at IS NULL)`; +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-tons.sql.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-tons.sql.spec.ts new file mode 100644 index 000000000..07067c3ed --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-tons.sql.spec.ts @@ -0,0 +1,30 @@ +import { bookingTonsSql } from './booking-tons.sql'; + +describe('bookingTonsSql', () => { + const sql = bookingTonsSql('b'); + + // The regression this exists for: a plain COALESCE stops at the portal's + // literal 0 for container bookings and reports them as weighing nothing. + it('treats a stored 0 as "no figure" on both booking-level columns', () => { + expect(sql).toContain('NULLIF(b.bulk_total_weight_tons, 0)'); + expect(sql).toContain('NULLIF(b.cargo_total_weight_vgm, 0)'); + }); + + it('falls back to the per-line container VGM, excluding soft-deleted lines', () => { + expect(sql).toContain('SUM(bc.total_vgm_tons)'); + expect(sql).toContain('freight.booking_container bc'); + expect(sql).toContain('bc.booking_id = b.id'); + expect(sql).toContain('bc.deleted_at IS NULL'); + }); + + it('never returns NULL, so callers may SUM it directly', () => { + expect(sql.trimEnd().endsWith('0)')).toBe(true); + }); + + it('rewrites every reference when embedded under another alias', () => { + const aliased = bookingTonsSql('bk'); + expect(aliased).not.toMatch(/\bb\./); + expect(aliased).toContain('bk.cargo_total_weight_vgm'); + expect(aliased).toContain('bc.booking_id = bk.id'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-tons.sql.ts b/apps/edr-freight-api/src/modules/bookings/booking-tons.sql.ts new file mode 100644 index 000000000..f3a8590a2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-tons.sql.ts @@ -0,0 +1,26 @@ +/** + * SQL mirror of `bookingCargoTons()` (train-scheduling/train-capacity.util.ts). + * + * Three storage conventions share `bookings.cargo_total_weight_vgm`: + * - BULK PER_TON — the column holds tons. + * - BULK PER_ITEM — the column holds an ITEM COUNT; the tons are in + * `bulk_total_weight_tons`. + * - CONTAINER — the portal wizard captures VGM per line, not per booking, + * and sends 0 (portal NewBookingPage: "containers carry NO weight at the + * wizard"). The tons live in `booking_container.total_vgm_tons`. The + * backoffice wizard does store a booking-level total, so both shapes exist + * in the same table. + * + * Hence NULLIF on both columns: a plain + * `COALESCE(bulk_total_weight_tons, cargo_total_weight_vgm)` stops at the + * portal's 0 — COALESCE falls through on NULL, never on 0 — and every + * portal-created container booking reads as 0 tons in exports and reports. + */ +export function bookingTonsSql(alias = 'b'): string { + return `COALESCE( + NULLIF(${alias}.bulk_total_weight_tons, 0), + NULLIF(${alias}.cargo_total_weight_vgm, 0), + (SELECT SUM(bc.total_vgm_tons) FROM freight.booking_container bc + WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL), + 0)`; +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 264ffb811..7324da975 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -370,7 +370,21 @@ export class BookingTransitionService { async startTransit(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ["PAID"]); + // Paid is read from the PAYMENT status only; the booking status merely + // guards against re-entering transit from a later stage. + if (booking.paymentStatus !== "PAID") { + throw new ConflictException( + `Booking must be paid before it can start transit (payment status "${booking.paymentStatus ?? "PENDING"}")`, + ); + } + assertBookingStatus(booking, [ + "PAID", + "FULLY_EXECUTED", + "PNR_GENERATED", + "WAGON_ASSIGNED", + "READY_FOR_ASSIGNMENT", + "APPROVED", + ]); const updated = await this.bookingsRepository.update(bookingId, { status: "IN_TRANSIT", @@ -1739,6 +1753,7 @@ export class BookingTransitionService { // (portal and backoffice). Degrades to null like every fragile field here. let trainSchedule: { trainNumber: string | null; + voyageNumber: string | null; reference: string | null; scheduledDepartureDate: Date | null; } | null = null; @@ -1750,6 +1765,8 @@ export class BookingTransitionService { if (s) { trainSchedule = { trainNumber: s.trainNumber ?? null, + // The schedule's own voyage (sailing) number shown to the customer. + voyageNumber: s.voyageNumber ?? null, reference: s.reference ?? null, scheduledDepartureDate: s.scheduledDepartureDate ?? 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 314d31a7f..0354dbc84 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 @@ -232,3 +232,91 @@ describe('BookingWagonCancellationService.buildRebookDto (bulk wagon count)', () expect(dto.requestedWagons).toBeUndefined(); }); }); + +/** + * The cancellation fee is paid BEFORE the credit is redeemed. + * + * An at-loading cut applies immediately and opens the credit while its fee + * invoice stays open, so CREDIT_AVAILABLE on its own never means the fee was + * settled. Without the gate the customer rebooks the same wagons and the + * cancellation fee is simply never collected. EDR-fault cuts carry no fee and + * must stay freely rebookable — partial or whole, container or bulk. + */ +describe('BookingWagonCancellationService.rebook (cancellation fee gate)', () => { + const source = { + id: 'b1', + contractId: 'c1', + paymentCurrency: 'ETB', + originYardId: 'y1', + destinationYardId: 'y2', + tradeDirection: 'IMPORT', + }; + + const makeSvc = (row: Record) => { + 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 () => null, + }; + return svc; + }; + + /** Bulk credit — no bySize, so nothing depends on container snapshots. */ + const bulkRow = (over: Record) => ({ + id: 'wc1', + bookingId: 'b1', + status: 'CREDIT_AVAILABLE', + creditAmount: 5000, + wagonsCancelled: 2, + cancelledQuantities: { bulkTons: 100 }, + feeCurrency: 'ETB', + ...over, + }); + + it('blocks a rebook while a customer-fault fee is unpaid', async () => { + const svc = makeSvc( + bulkRow({ fault: 'CUSTOMER', feeAmount: 1500, feePaidAt: null }), + ); + await expect(svc.rebook('wc1', { scheduledDate: '2026-09-01' })).rejects.toThrow( + /pay the ETB 1500\.00 cancellation fee for 2 wagon\(s\)/i, + ); + }); + + it('blocks a WHOLE-booking customer-fault cancel just the same', async () => { + const svc = makeSvc( + bulkRow({ fault: 'CUSTOMER', feeAmount: 4000, feePaidAt: null, wagonsCancelled: 4 }), + ); + await expect(svc.rebook('wc1', { scheduledDate: '2026-09-01' })).rejects.toThrow( + /4 wagon\(s\) before rebooking/i, + ); + }); + + it('lets the rebook through once the fee is paid', async () => { + const svc = makeSvc( + bulkRow({ fault: 'CUSTOMER', feeAmount: 1500, feePaidAt: new Date() }), + ); + // Past the gate it fails later (no contract/create wiring in this harness) — + // what matters is that it is no longer the fee that stops it. + await expect( + svc.rebook('wc1', { scheduledDate: '2026-09-01' }), + ).rejects.not.toThrow(/cancellation fee/i); + }); + + it('never charges an EDR-fault cut', async () => { + const svc = makeSvc(bulkRow({ fault: 'EDR', feeAmount: 0, feePaidAt: null })); + await expect( + svc.rebook('wc1', { scheduledDate: '2026-09-01' }), + ).rejects.not.toThrow(/cancellation fee/i); + }); + + it('leaves legacy rows without a fee untouched', async () => { + const svc = makeSvc(bulkRow({ fault: null, feeAmount: 0, feePaidAt: null })); + await expect( + svc.rebook('wc1', { scheduledDate: '2026-09-01' }), + ).rejects.not.toThrow(/cancellation fee/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 a93089e7e..4507e6479 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 @@ -53,6 +53,8 @@ import { CancelledUnitSnapshot, WAGON_CANCEL_FEE_INVOICE_TYPE, } from './entities/booking-wagon-cancellation.entity'; +import { WagonEventType } from '@edr/types'; +import { WagonHistoryService } from '../wagon-history/wagon-history.service'; export { WAGON_CANCEL_FEE_INVOICE_TYPE }; @@ -134,6 +136,7 @@ export class BookingWagonCancellationService { private readonly firstMile: FirstMileService, private readonly inbox: NotificationInboxService, private readonly events: EventEmitter2, + private readonly wagonHistory: WagonHistoryService, ) {} // ── T1: request ──────────────────────────────────────────────────────────── @@ -1003,6 +1006,18 @@ export class BookingWagonCancellationService { 'This cancellation has no rebooking credit — the booking was never paid. Create a new booking instead.', ); } + // Customer-fault fee settles BEFORE the credit is redeemed. An at-loading + // cut applies immediately and opens the credit while its invoice stays + // open, so CREDIT_AVAILABLE alone does not mean the fee was paid — without + // this the customer rebooks the wagons and never pays the cancellation + // fee the notice already promised. EDR fault carries no fee and is + // unaffected; onFeePaid stamps feePaidAt and the gate opens by itself. + if (row.fault === 'CUSTOMER' && Number(row.feeAmount) > 0 && !row.feePaidAt) { + throw new BadRequestException( + `Pay the ${row.feeCurrency} ${Number(row.feeAmount).toFixed(2)} cancellation fee for ` + + `${Math.ceil(Number(row.wagonsCancelled))} wagon(s) before rebooking this credit.`, + ); + } const source = await this.bookingsRepository.findById(row.bookingId); if (!source) throw new NotFoundException(`Booking ${row.bookingId} not found.`); if (!source.contractId) { @@ -1835,6 +1850,7 @@ export class BookingWagonCancellationService { .getRepository(WagonAllocationContainerItem) .delete(cut.map((i) => i.id)); if (cut.length === items.length) { + await this.recordAllocationRelease(manager, [alloc.id], bookingId, 'Containers cancelled from booking'); await manager.getRepository(WagonBookingAllocation).delete(alloc.id); } else { const cutWeight = cut.reduce((s, i) => s + Number(i.grossWeightTons ?? 0), 0); @@ -1881,9 +1897,66 @@ export class BookingWagonCancellationService { await manager .getRepository(WagonAllocationBulkLoad) .delete({ wagonBookingAllocationId: In(ids) }); + await this.recordAllocationRelease(manager, ids, bookingId, 'Wagons cancelled from booking'); await manager.getRepository(WagonBookingAllocation).delete(ids); } + /** + * BOOKING_CANCELLED history row for every physical wagon behind the released + * allocations — resolved through the slot BEFORE the allocation rows go, one + * query for the whole batch. Slots with no wagon pinned yet leave no row. + */ + private async recordAllocationRelease( + manager: EntityManager, + allocationIds: string[], + bookingId: string, + reason: string, + ): Promise { + if (!allocationIds.length) return; + const rows: Array<{ + allocationId: string; + wagonId: string; + wagonNumber: string; + yardId: string | null; + trainId: string | null; + scheduleId: string | null; + weightTons: string | null; + loadType: string | null; + }> = await manager.query( + `SELECT a.id AS "allocationId", + w.id AS "wagonId", + w.wagon_number AS "wagonNumber", + w.current_yard_id AS "yardId", + w.train_id AS "trainId", + w.current_train_schedule_id AS "scheduleId", + a.allocated_weight_tons AS "weightTons", + a.load_type AS "loadType" + FROM freight.wagon_booking_allocations a + JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id + JOIN freight.wagons w ON w.id = tsw.physical_wagon_id + WHERE a.id = ANY($1::uuid[])`, + [allocationIds], + ); + await this.wagonHistory.record( + manager, + rows.map((r) => ({ + wagonId: r.wagonId, + wagonNumber: r.wagonNumber, + type: WagonEventType.BookingCancelled, + fromYardId: r.yardId, + trainId: r.trainId, + trainScheduleId: r.scheduleId, + bookingId, + reason, + metadata: { + allocationId: r.allocationId, + loadType: r.loadType, + weightTons: r.weightTons == null ? null : Number(r.weightTons), + }, + })), + ); + } + /** Pre-reduction quantities snapshot (only when the booking was never split before). */ private async currentQuantities( manager: EntityManager, @@ -1951,9 +2024,12 @@ export class BookingWagonCancellationService { // the same cargo); number/seal/VGM come from the override when given. units: sized.map((u, i) => ({ containerNumber: replacement?.[i]?.containerNumber ?? u.containerNumber, + // A credit snapshot taken before seals were mandatory can carry + // none; the booking service normalizes the blank back to null + // rather than blocking the rebook of already-paid cargo. sealNumber: replacement - ? (replacement[i]?.sealNumber ?? undefined) - : (u.sealNumber ?? undefined), + ? (replacement[i]?.sealNumber ?? '') + : (u.sealNumber ?? ''), vgmTons: replacement?.[i]?.vgmTons ?? u.vgmTons, isHazardous: u.isHazardous, isReefer: u.isReefer, 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 37216f9f1..2d38592fb 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -87,6 +87,7 @@ import { import { ContractViewDto } from "./dto/contract-view.dto"; import { CustomerTruckAssignmentDto } from "./dto/customer-truck-assignment.dto"; import { AddCustomerTruckDto } from "./dto/add-customer-truck.dto"; +import { BulkCustomerTrucksDto } from "./dto/bulk-customer-truck.dto"; import { DepartCustomerTruckDto } from "./dto/depart-customer-truck.dto"; import { LoadCustomerTruckDto } from "./dto/load-customer-truck.dto"; import { CustomerTruckService } from "./customer-truck.service"; @@ -665,9 +666,11 @@ export class BookingsController { @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); + // GL (createBooking) rebooks credits and must see the ledger for that. const staff = hasFreightPermission(user, FREIGHT_PERMS.bookings.view) || - hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView); + hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView) || + hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking); if (!staff) { await this.bookingsService.assertCustomerCanAccessBooking( user?.id, @@ -848,6 +851,14 @@ export class BookingsController { staffPermission: string, ): Promise { if (hasFreightPermission(user, staffPermission)) return; + // Rebooking a credit creates a booking under the contract — GL's booking + // creation key covers it even where the dedicated rebook key was never granted. + if ( + staffPermission === FREIGHT_PERMS.bookings.wagonCancellationRebook && + hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking) + ) { + return; + } const row = await this.wagonCancellationService.findById(cancellationId); const booking = await this.bookingsService.findById(row.bookingId); await this.bookingsService.assertCustomerCanAccessBooking( @@ -907,7 +918,7 @@ export class BookingsController { }) async bulkAddCustomerTrucks( @Param("id", ParseUUIDPipe) id: string, - @Body() payload: { trucks: AddCustomerTruckDto[] }, + @Body() payload: BulkCustomerTrucksDto, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); 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 7785bcb0f..863b61fee 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -21,6 +21,13 @@ import { ShippingLineCompany } from '../shipping-lines/entities/shipping-line-co import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; import { ContractRoute } from '../contracts/entities/contract-route.entity'; import { applyDirectionScope } from '../user-trade-access/trade-scope.util'; +import { + CARGO_TYPE_SUBTREE_SQL, + bookingContainerCountSql, + bookingContentMatchSql, + bookingHasContainerTypeSql, + bookingRequestedContainerCountSql, +} from './booking-content.sql'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; import { BookingDocumentReview, @@ -65,7 +72,17 @@ export interface BookingListFilterOptions { contractId?: string; contractType?: string; serviceTypeId?: string; + /** Cargo type OR cargo group — a group matches every commodity beneath it. */ cargoTypeId?: string; + /** Contains-search over content: description, commodity name, container types. */ + cargoText?: string; + /** Bookings carrying this container type; also scopes the container count. */ + containerTypeId?: string; + containersMin?: number; + containersMax?: number; + /** Bounds on containers declared on the shipment request behind the booking. */ + requestedContainersMin?: number; + requestedContainersMax?: number; freightType?: string; bookingType?: string; tradeDirection?: string; @@ -1176,11 +1193,55 @@ export class BookingsRepository extends BaseRepository { serviceTypeId: options.serviceTypeId, }); } + // A group is selectable in the filter, not just a leaf commodity, so this + // matches the whole subtree — picking "Bulk" must return every commodity + // under it, the same drill-down the booking wizard offers, read back. if (options.cargoTypeId) { - qb.andWhere('booking.cargo_type_id = :cargoTypeId', { + qb.andWhere(`booking.cargo_type_id IN ${CARGO_TYPE_SUBTREE_SQL}`, { cargoTypeId: options.cargoTypeId, }); } + if (options.cargoText) { + qb.andWhere(bookingContentMatchSql('booking'), { + cargoText: `%${options.cargoText}%`, + }); + } + if (options.containerTypeId) { + qb.andWhere(bookingHasContainerTypeSql('booking'), { + containerTypeId: options.containerTypeId, + }); + } + // One count filter, two questions: with a container type picked it counts + // that type, without one it counts every box on the booking. + if (options.containersMin != null || options.containersMax != null) { + const count = bookingContainerCountSql( + 'booking', + Boolean(options.containerTypeId), + ); + if (options.containersMin != null) { + qb.andWhere(`${count} >= :containersMin`, { + containersMin: options.containersMin, + }); + } + if (options.containersMax != null) { + qb.andWhere(`${count} <= :containersMax`, { + containersMax: options.containersMax, + }); + } + } + // Declared on the shipment request, not on the booking. Pairs with the + // count above: containers 0..0 AND requested >= 1 is the set awaiting + // completion after clearance. + if (options.requestedContainersMin != null) { + qb.andWhere(`${bookingRequestedContainerCountSql('booking')} >= :requestedContainersMin`, { + requestedContainersMin: options.requestedContainersMin, + }); + } + if (options.requestedContainersMax != null) { + qb.andWhere(`${bookingRequestedContainerCountSql('booking')} <= :requestedContainersMax`, { + requestedContainersMax: options.requestedContainersMax, + }); + } if (omit !== 'freightType' && options.freightType) { qb.andWhere('booking.freight_type = :freightType', { freightType: options.freightType, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index e9bd01a86..c7294a004 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -29,6 +29,11 @@ import { DataSource, In } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate'; +import { + EDR_HAULAGE_CONFLICT_MESSAGE, + LAST_MILE_COMMITTED_SQL, + edrHaulsThisBooking, +} from '../../common/mile-haulage.util'; import { Yard } from '../rule-engine/entities/yard.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; @@ -112,8 +117,13 @@ interface CarriageAcceptanceWagonRow { departureAt: Date | null; marshalledAt: string | null; arrivalAt: string | null; + /** Per-row stations: the slot's own board/alight yard, else the schedule's endpoints. */ + departureStation: string | null; + arrivalStation: string | null; containerNumbers: string | null; sealNumbers: string | null; + /** Allocation status — LOADED/DEPARTED means EDR has the cargo. */ + status: string | null; } /** A received-but-not-yet-marshalled export line, standing in for a wagon row. */ @@ -169,18 +179,23 @@ export class BookingsService { dto: CustomerTruckAssignmentDto, ): Promise { const booking = await this.findById(bookingId); - const hasFirstMile = Boolean(booking.firstMilePickupAddress?.trim()); - const hasLastMile = Boolean(booking.lastMileDeliveryAddress?.trim()); - const usesMileService = - booking.tradeDirection === 'IMPORT' - ? hasLastMile - : booking.tradeDirection === 'EXPORT' - ? hasFirstMile - : hasFirstMile || hasLastMile; - if (usesMileService) { - throw new BadRequestException( - 'Customer truck assignment is only allowed when first/last mile delivery is not selected', - ); + // Same rule as CustomerTruckService.assertSelfHaulPaid: an EDR delivery leg + // closes self-haul only once it has been approved. + const [commitment]: Array<{ lastMileCommitted: boolean }> = await this.dataSource.query( + `SELECT ${LAST_MILE_COMMITTED_SQL} AS "lastMileCommitted" + FROM freight.bookings b + WHERE b.id = $1`, + [bookingId], + ); + if ( + edrHaulsThisBooking({ + tradeDirection: booking.tradeDirection ?? null, + firstMile: booking.firstMilePickupAddress ?? null, + lastMile: booking.lastMileDeliveryAddress ?? null, + lastMileCommitted: Boolean(commitment?.lastMileCommitted), + }) + ) { + throw new BadRequestException(EDR_HAULAGE_CONFLICT_MESSAGE); } if (booking.customerTruckAssignedAt) { throw new ConflictException('Customer truck assignment is already submitted and locked'); @@ -263,9 +278,13 @@ export class BookingsService { /** * Carriage acceptance sheet — one per booking, listing every wagon the booking - * occupies. Handed to the customer when EDR accepts the cargo (export) and when - * the wagons are allocated before marshalling (import), so it is only available - * once the booking has wagon allocations. + * occupies. A booking is routinely loaded in parts (some containers go, the + * rest wait for the next train), so each row carries a Status of Loaded or + * Not loaded and the totals count only the loaded ones: the customer sees the + * whole plan on one page without the sheet overstating what EDR has taken. + * + * Handed to the customer when EDR accepts the cargo (export) and when the + * wagons are allocated before marshalling (import). */ async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> { const booking = await this.findById(bookingId); @@ -285,6 +304,9 @@ export class BookingsService { s.scheduled_departure_date AS "departureAt", so.label AS "marshalledAt", sd.label AS "arrivalAt", + COALESCE(by_.label, so.label) AS "departureStation", + COALESCE(ay.label, sd.label) AS "arrivalStation", + a.status AS "status", string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers", string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers" FROM freight.wagon_booking_allocations a @@ -296,13 +318,31 @@ export class BookingsService { ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL LEFT JOIN freight.yards so ON so.id = s.origin_station_id LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id + LEFT JOIN freight.yards by_ ON by_.id = tsw.board_yard_id + LEFT JOIN freight.yards ay ON ay.id = tsw.alight_yard_id LEFT JOIN freight.wagon_allocation_container_items ci ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL + AND ( + $2 <> 'EXPORT' OR $3 <> 'CONTAINER' OR EXISTS ( + SELECT 1 + FROM freight.booking_container_units received_unit + JOIN freight.booking_container received_line + ON received_line.id = received_unit.booking_container_id + AND received_line.deleted_at IS NULL + WHERE received_line.booking_id = a.booking_id + AND received_unit.container_number = ci.container_number + AND received_unit.received_to_port = true + AND NULLIF(TRIM(received_unit.grn_number), '') IS NOT NULL + AND received_unit.deleted_at IS NULL + ) + ) WHERE a.booking_id = $1 AND a.deleted_at IS NULL - GROUP BY tsw.id, a.id, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons, - s.train_number, s.scheduled_departure_date, so.label, sd.label + GROUP BY tsw.id, a.id, a.status, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons, + s.train_number, s.scheduled_departure_date, so.label, sd.label, + by_.label, ay.label + HAVING $2 <> 'EXPORT' OR $3 <> 'CONTAINER' OR COUNT(ci.id) > 0 ORDER BY tsw.sequence_no`, - [bookingId], + [bookingId, booking.tradeDirection, booking.freightType], ); // Export acceptance happens at the warehouse gate, not at marshalling: EDR // takes custody of the cargo when it receives it, and the customer is handed @@ -337,17 +377,17 @@ export class BookingsService { ) : booking.tradeDirection === 'EXPORT' ? await this.dataSource.query( - `SELECT inv.weight AS "allocatedWeightTons", - c.container_number AS "containerNumbers" - FROM freight.warehouse_inventory inv - LEFT JOIN freight.containers c - ON c.id = inv.container_id AND c.deleted_at IS NULL - WHERE inv.booking_id = $1 AND inv.deleted_at IS NULL - AND COALESCE( - NULLIF(TRIM(inv.grn_number), ''), - substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') - ) IS NOT NULL - ORDER BY inv.created_at`, + `SELECT unit.vgm_tons AS "allocatedWeightTons", + unit.container_number AS "containerNumbers", + unit.seal_number AS "sealNumbers" + FROM freight.booking_container_units unit + JOIN freight.booking_container line + ON line.id = unit.booking_container_id AND line.deleted_at IS NULL + WHERE line.booking_id = $1 + AND unit.deleted_at IS NULL + AND unit.received_to_port = true + AND NULLIF(TRIM(unit.grn_number), '') IS NOT NULL + ORDER BY unit.received_at, unit.container_number`, [bookingId], ) : []; @@ -381,8 +421,12 @@ export class BookingsService { departureAt: null, marshalledAt: null, arrivalAt: null, + departureStation: null, + arrivalStation: null, containerNumbers: row.containerNumbers, sealNumbers: row.sealNumbers ?? null, + // A received line has no allocation; it is cargo EDR already holds. + status: null, })); } @@ -497,7 +541,17 @@ export class BookingsService { const header = wagons[0]; const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date(); - const totals = wagons.reduce( + // Loaded = EDR has the cargo. A booking is routinely loaded in parts, so the + // totals count only those: the sheet shows the whole plan, but must never + // total up cargo still sitting in the yard. A received-line sheet + // (pendingWagons) has no allocation status, and every line on it is cargo + // already accepted, so it counts in full. + const isLoaded = (w: CarriageAcceptanceWagonRow) => + pendingWagons || w.status === 'LOADED' || w.status === 'DEPARTED'; + const loadedWagons = wagons.filter(isLoaded); + const notLoadedCount = wagons.length - loadedWagons.length; + + const totals = loadedWagons.reduce( (acc, w) => ({ tare: acc.tare + (Number(w.tareWeightTons) || 0), capacity: acc.capacity + (Number(w.loadCapacityTons) || 0), @@ -507,7 +561,7 @@ export class BookingsService { { tare: 0, capacity: 0, load: 0, length: 0 }, ); // A wagon carrying no weight and no container is running empty under this booking. - const fullWagons = wagons.filter( + const fullWagons = loadedWagons.filter( (w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers), ).length; @@ -520,11 +574,14 @@ export class BookingsService { ${num(w.tareWeightTons, 2)} ${num(w.equatedLength)} ${num(w.loadCapacityTons)} - ${esc(arrivalStation)} + ${esc(w.arrivalStation ?? arrivalStation)} ${esc(cargoName)} - ${esc(departureStation)} + ${esc(w.departureStation ?? departureStation)} ${esc(w.containerNumbers)} ${esc(w.sealNumbers)} + ${ + pendingWagons ? 'Accepted' : isLoaded(w) ? 'Loaded' : 'Not loaded' + } ${money(prices[i])} `, ) @@ -535,23 +592,37 @@ export class BookingsService { // figure from the printed sheet. const totalsRow = ` TOT - ${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'} - ${ - pendingWagons - ? 'pending marshalling' - : `full ${fullWagons} / empty ${wagons.length - fullWagons}` - } + ${loadedWagons.length} ${pendingWagons ? 'received lines' : 'wagons loaded'} + ${num(totals.tare, 2)} ${num(totals.length)} ${num(totals.capacity)} - Gross ${num(totals.tare + totals.load)} T + + ${notLoadedCount > 0 ? `loaded only (${notLoadedCount} not loaded)` : ''} ${money(totalAmount)} `; + // The signed footer of the paper sheet. Rendered as .tile so the + // Chromium-less fallback (buildTabularFallbackPdf parses .tile, not + // arbitrary divs) still prints every figure. + const footer = ` + `; + return ` @@ -568,6 +639,8 @@ export class BookingsService { .meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; } .meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; } .summary { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 14px 0; } + .footer-summary { grid-template-columns: repeat(8, 1fr); margin: 10px 0 0; } + .footer-summary .tile { background: #f8fafc; } .tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; } .tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; } .tile strong { font-size: 11px; } @@ -575,6 +648,8 @@ export class BookingsService { th { background: #f8fafc; color: #475569; text-align: left; } th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; } .num { text-align: right; } + .loaded { color: #0f766e; font-weight: 700; } + .pending { color: #b45309; font-weight: 700; } tr.totals td { background: #f8fafc; font-weight: 700; } .notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; } .signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; } @@ -618,6 +693,7 @@ export class BookingsService { Departure Station Container No. Seal No. + Status Price (${esc(currency)}) @@ -626,6 +702,7 @@ export class BookingsService { ${totalsRow} +${footer}
${ @@ -1845,6 +1922,12 @@ export class BookingsService { contractType: filter.contractType, serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, + cargoText: filter.cargoText, + containerTypeId: filter.containerTypeId, + containersMin: filter.containersMin, + containersMax: filter.containersMax, + requestedContainersMin: filter.requestedContainersMin, + requestedContainersMax: filter.requestedContainersMax, freightType: filter.freightType, bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, @@ -2131,6 +2214,12 @@ export class BookingsService { contractType: filter.contractType, serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, + cargoText: filter.cargoText, + containerTypeId: filter.containerTypeId, + containersMin: filter.containersMin, + containersMax: filter.containersMax, + requestedContainersMin: filter.requestedContainersMin, + requestedContainersMax: filter.requestedContainersMax, freightType: filter.freightType, bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, diff --git a/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts b/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts index 195e0cab0..45907c09a 100644 --- a/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts @@ -24,3 +24,84 @@ describe('carriage acceptance sheet — price split', () => { expect(shares).toEqual([33.33, 33.33, 33.34]); }); }); + +// The HTML builder only reaches `this` for two prototype helpers (escapeHtml, +// splitAmountAcrossWagons), so the prototype itself serves as `this`. +const buildSheet = (wagons: unknown[], booking: Record = {}): string => + ( + BookingsService.prototype as unknown as { + buildCarriageAcceptanceSheetHtml( + b: unknown, + w: unknown[], + o: { pendingWagons: boolean }, + ): string; + } + ).buildCarriageAcceptanceSheetHtml.call( + BookingsService.prototype, + { + reference: 'BK-1', + tradeDirection: 'EXPORT', + totalAmount: 100, + paymentCurrency: 'ETB', + originYard: { label: 'Booking Origin' }, + destinationYard: { label: 'Booking Destination' }, + ...booking, + }, + wagons, + { pendingWagons: false }, + ); + +const wagon = (over: Record = {}) => ({ + sequenceNo: 1, + wagonType: 'FLAT', + wagonNumber: 'W-001', + tareWeightTons: '20', + equatedLength: '14', + loadCapacityTons: '60', + allocatedWeightTons: '40', + trainNumber: '8302', + departureAt: null, + marshalledAt: 'DCT/SGTD', + arrivalAt: 'GMP', + departureStation: null, + arrivalStation: null, + containerNumbers: 'CN-1', + sealNumbers: 'SL-1', + status: 'LOADED', + ...over, +}); + +describe('carriage acceptance sheet — rows and footer', () => { + it('prints each row its own Departure/Arrival Station, falling back to the booking yards', () => { + const html = buildSheet([ + wagon({ departureStation: 'Dire Dawa Port', arrivalStation: 'Adama' }), + wagon({ sequenceNo: 2, wagonNumber: 'W-002' }), + ]); + expect(html).toContain('Dire Dawa Port'); + expect(html).toContain('Adama'); + expect(html).toContain('Booking Origin'); + expect(html).toContain('Booking Destination'); + }); + + it('totals the footer over loaded wagons only', () => { + const html = buildSheet([ + wagon(), + wagon({ sequenceNo: 2, wagonNumber: 'W-002', status: 'ALLOCATED' }), + wagon({ + sequenceNo: 3, + wagonNumber: 'W-003', + allocatedWeightTons: '0', + containerNumbers: null, + }), + ]); + // 2 loaded of 3: tare 40, capacity 120, equated length 28, gross 40 + 40 load. + expect(html).toContain('In Total Wagon No.2'); + expect(html).toContain('Tare Weight (T)40.00'); + expect(html).toContain('Load Capacity (T)120.000'); + expect(html).toContain('Gross Weight (T)80.000'); + expect(html).toContain('Equated Length28.000'); + expect(html).toContain('Full Wagon1'); + expect(html).toContain('Empty Wagon1'); + expect(html).toContain('Total Amount (ETB)100.00'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts index 3df681d9c..88df3d80e 100644 --- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -9,12 +9,17 @@ import { DataSource, EntityManager, IsNull } from 'typeorm'; import { NotificationAudience, NotificationType } from '@edr/types'; import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; +import type { + BulkTruckUploadError, + BulkTruckUploadResult, +} from './dto/bulk-customer-truck.dto'; import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity'; import { CustomerTruckContainer } from './entities/customer-truck-container.entity'; import { EDR_HAULAGE_CONFLICT_MESSAGE, - usesEdrMileService, + LAST_MILE_COMMITTED_SQL, + edrHaulsThisBooking, } from '../../common/mile-haulage.util'; import { assertBulkTonnageRemains, @@ -35,6 +40,9 @@ interface BookingGuardRow { lastMile: string | null; paymentStatus: string | null; status: string | null; + trainScheduleStatus: string | null; + /** See `MileCommitmentRow` — an approved EDR last-mile leg closes self-haul. */ + lastMileCommitted: boolean; } /** @@ -294,19 +302,16 @@ export class CustomerTruckService { } const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + if (booking.freightType === 'CONTAINER' && !requested.length) { + throw new BadRequestException('Select the containers loaded on this truck'); + } if (requested.length) { - const bookingNumbers = await this.bookingContainerNumbers(bookingId); - for (const n of requested) { - if (!bookingNumbers.includes(n)) { - throw new BadRequestException(`Container ${n} is not one of this booking's containers`); - } - } - const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId); - for (const n of requested) { - if (elsewhere.includes(n)) { - throw new ConflictException(`Container ${n} is already loaded onto another truck`); - } - } + assertTruckLoad({ + containers: requested, + bookingContainers: await this.bookingContainerNumbers(bookingId), + sizes: await bookingContainerSizes(this.dataSource, bookingId, requested), + assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId), + }); } await this.dataSource.transaction(async (manager) => { @@ -542,9 +547,17 @@ export class CustomerTruckService { first_mile_pickup_address AS "firstMile", last_mile_delivery_address AS "lastMile", payment_status AS "paymentStatus", - status - FROM freight.bookings - WHERE id = $1 AND deleted_at IS NULL`, + b.status, + (SELECT ts.status + FROM freight.train_schedule_bookings tsb + JOIN freight.train_schedules ts + ON ts.id = tsb.train_schedule_id AND ts.deleted_at IS NULL + WHERE tsb.booking_id = b.id AND tsb.deleted_at IS NULL + ORDER BY ts.updated_at DESC + LIMIT 1) AS "trainScheduleStatus", + ${LAST_MILE_COMMITTED_SQL} AS "lastMileCommitted" + FROM freight.bookings b + WHERE b.id = $1 AND b.deleted_at IS NULL`, [bookingId], ); if (!row) throw new NotFoundException(`Booking ${bookingId} not found`); @@ -552,10 +565,12 @@ export class CustomerTruckService { } private assertSelfHaulPaid(booking: BookingGuardRow): void { - // Shared with the EDR side (LastMileService.assertNoCustomerTruck) so the two - // halves of this rule cannot drift apart — they did, and a booking ended up - // with a customer truck and an EDR leg at once. - if (usesEdrMileService(booking)) { + // Mirrors the EDR side (LastMileService.assertEdrHaulsThisBooking) so the + // two halves of this rule cannot drift apart — they did, and a booking ended + // up with a customer truck and an EDR leg at once. A last-mile leg only + // blocks self-haul once it is approved; until then the customer may still + // bring their own truck, and doing so makes the pending request unapprovable. + if (edrHaulsThisBooking(booking)) { throw new BadRequestException(EDR_HAULAGE_CONFLICT_MESSAGE); } if (booking.paymentStatus !== 'PAID') { @@ -575,7 +590,7 @@ export class CustomerTruckService { private assertAssignmentWindow(booking: BookingGuardRow): void { const status = booking.status ?? ''; if (booking.tradeDirection === 'IMPORT') { - if (status !== 'ARRIVED') { + if (status !== 'ARRIVED' && booking.trainScheduleStatus !== 'ARRIVED') { throw new BadRequestException( 'Import pickup trucks can only be assigned after the train has arrived', ); @@ -626,26 +641,29 @@ export class CustomerTruckService { /** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */ + /** + * Add trucks one at a time, keeping the good ones. Partial success is the + * right shape here: one mistyped plate in a twenty-row spreadsheet should not + * discard the other nineteen trucks. Every row still goes through `addTruck`, + * so no guard is skipped. + */ async addBulkTrucks( bookingId: string, dtos: AddCustomerTruckDto[], - ): Promise<{ - success: number; - failed: number; - errors: Array<{ row: number; truck: string; reason: string }>; - }> { - const errors: Array<{ row: number; truck: string; reason: string }> = []; + ): Promise { + const errors: BulkTruckUploadError[] = []; let successCount = 0; for (let i = 0; i < dtos.length; i++) { try { await this.addTruck(bookingId, dtos[i]); successCount++; - } catch (err: any) { + } catch (err) { errors.push({ - row: i + 2, // Row 1 is header + index: i, + row: i + 2, // Row 1 is the header truck: dtos[i].truckPlateNumber, - reason: err.message || 'Unknown error', + reason: err instanceof Error ? err.message : 'Unknown error', }); } } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts index 9816b3405..1085f954a 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts @@ -12,7 +12,7 @@ import { Min, } from 'class-validator'; -import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto'; +import { CUSTOMER_TRUCK_TYPES, ISO_CONTAINER_NUMBER } from '@edr/types'; /** * Add one external customer truck to a booking. @@ -41,7 +41,7 @@ export class AddCustomerTruckDto { @IsArray() @ArrayMaxSize(2) @ArrayUnique() - @Matches(/^[A-Z]{4}\d{7}$/, { + @Matches(ISO_CONTAINER_NUMBER, { each: true, message: 'each container number must match ISO container format, e.g. ABCD1234567', }) diff --git a/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts index 5e03c7bc4..e3249e684 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts @@ -1,48 +1,41 @@ -import { IsString, IsNotEmpty, IsIn, IsArray, ArrayMaxSize, ArrayUnique, Matches, IsOptional } from 'class-validator'; -import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto'; +import { ArrayMaxSize, ArrayMinSize, IsArray, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; -export class BulkCustomerTruckRow { - @IsString() - @IsNotEmpty() - truckPlateNumber!: string; - - @IsString() - @IsNotEmpty() - driverName!: string; - - @IsString() - @IsNotEmpty() - @IsIn(CUSTOMER_TRUCK_TYPES) - truckType!: string; - - @IsOptional() - @IsArray() - @ArrayMaxSize(2) - @ArrayUnique() - @Matches(/^[A-Z]{4}\d{7}$/, { - each: true, - message: 'each container must be ISO format (e.g. ABCD1234567)', - }) - containerNumbers?: (string | null)[]; -} +import { AddCustomerTruckDto } from './add-customer-truck.dto'; +/** + * Bulk self-haul truck assignment, parsed from the customer's Excel upload in + * the browser and posted as JSON (the house pattern — the API never receives an + * .xlsx for import). + * + * Rows reuse `AddCustomerTruckDto` verbatim rather than redeclaring the fields: + * the earlier copy drifted, missing `plannedTons` / `plannedQuantity`, so bulk + * cargo could not be uploaded at all. + */ export class BulkCustomerTrucksDto { @IsArray() + @ArrayMinSize(1) @ArrayMaxSize(100) - trucks!: BulkCustomerTruckRow[]; + @ValidateNested({ each: true }) + @Type(() => AddCustomerTruckDto) + trucks!: AddCustomerTruckDto[]; +} + +export interface BulkTruckUploadError { + /** + * Position in the submitted array. The client knows which spreadsheet line it + * read each entry from, so it maps this back to the row number the customer + * actually sees. + */ + index: number; + /** 1-based row assuming a single header line — a fallback for non-Excel callers. */ + row: number; + truck: string; + reason: string; } export interface BulkTruckUploadResult { success: number; failed: number; - errors: Array<{ - row: number; - truck: string; - reason: string; - }>; - created: Array<{ - truckPlateNumber: string; - driverName: string; - containers: number; - }>; + errors: BulkTruckUploadError[]; } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/customer-truck-assignment.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/customer-truck-assignment.dto.ts index 9daf7523e..e4655a739 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/customer-truck-assignment.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/customer-truck-assignment.dto.ts @@ -1,12 +1,12 @@ import { IsIn, IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator'; +import { CUSTOMER_TRUCK_TYPES, ISO_CONTAINER_NUMBER } from '@edr/types'; -export const CUSTOMER_TRUCK_TYPES = [ - 'Flatbed', - 'Container Chassis', - 'Lowboy', - 'Box Truck', - 'Tipper', -] as const; +/** + * Re-exported for the DTOs that already import it from here. The list itself + * lives in `@edr/types` so the portal's dropdown and its Excel template read the + * same values this validator enforces. + */ +export { CUSTOMER_TRUCK_TYPES }; export class CustomerTruckAssignmentDto { @IsString() @@ -27,7 +27,7 @@ export class CustomerTruckAssignmentDto { @IsString() @IsNotEmpty() @MaxLength(16) - @Matches(/^[A-Z]{4}\d{7}$/, { + @Matches(ISO_CONTAINER_NUMBER, { message: 'containerNumberToLoad must match ISO container format, e.g. ABCD1234567', }) containerNumberToLoad!: string; diff --git a/apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts index 31ab1b5bd..d3e73267d 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts @@ -8,6 +8,7 @@ import { Matches, Min, } from 'class-validator'; +import { ISO_CONTAINER_NUMBER } from '@edr/types'; /** * Register an import self-haul truck leaving the port: the containers it actually @@ -20,7 +21,7 @@ export class DepartCustomerTruckDto { @IsArray() @ArrayMaxSize(2) @ArrayUnique() - @Matches(/^[A-Z]{4}\d{7}$/, { + @Matches(ISO_CONTAINER_NUMBER, { each: true, message: 'each container number must match ISO container format, e.g. ABCD1234567', }) diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index a404c005e..a6138e77b 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -62,11 +62,60 @@ export class FilterBookingDto { @IsUUID() serviceTypeId?: string; - @ApiPropertyOptional({ format: 'uuid' }) + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Cargo type OR cargo group — a group matches every commodity beneath it', + }) @IsOptional() @IsUUID() cargoTypeId?: string; + @ApiPropertyOptional({ + description: + 'Contains-search over booking content: cargo description, commodity name, container types', + }) + @IsOptional() + @Transform(({ value }) => + typeof value === 'string' && value.trim() ? value.trim() : undefined, + ) + cargoText?: string; + + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Bookings carrying this container type. Also scopes containersMin/Max to it.', + }) + @IsOptional() + @IsUUID() + containerTypeId?: string; + + @ApiPropertyOptional({ + description: + 'Minimum container count — of containerTypeId when set, else of all types', + }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + containersMin?: number; + + @ApiPropertyOptional({ description: 'Maximum container count — see containersMin' }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + containersMax?: number; + + @ApiPropertyOptional({ + description: + 'Minimum containers declared on the shipment request behind the booking', + }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + requestedContainersMin?: number; + + @ApiPropertyOptional({ description: 'Maximum requested containers — see requestedContainersMin' }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + requestedContainersMax?: number; + @ApiPropertyOptional({ enum: FREIGHT_TYPES }) @IsOptional() @IsIn([...FREIGHT_TYPES]) diff --git a/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts index 1386e5bd4..35a4f7e0e 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts @@ -1,4 +1,5 @@ import { ArrayMaxSize, ArrayMinSize, ArrayUnique, IsArray, Matches } from 'class-validator'; +import { ISO_CONTAINER_NUMBER } from '@edr/types'; /** Containers loaded onto a truck at Truck_dispatch (after arrival, before it leaves). */ export class LoadCustomerTruckDto { @@ -7,7 +8,7 @@ export class LoadCustomerTruckDto { // A truck carries at most 2 containers (two 20ft, or one 40ft). @ArrayMaxSize(2) @ArrayUnique() - @Matches(/^[A-Z]{4}\d{7}$/, { + @Matches(ISO_CONTAINER_NUMBER, { each: true, message: 'each container number must match ISO container format, e.g. ABCD1234567', }) diff --git a/apps/edr-freight-api/src/modules/chat/chat-bridge.service.spec.ts b/apps/edr-freight-api/src/modules/chat/chat-bridge.service.spec.ts new file mode 100644 index 000000000..05dc84b29 --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/chat-bridge.service.spec.ts @@ -0,0 +1,78 @@ +import 'reflect-metadata'; + +import { NotificationType, type NotifyInput } from '@edr/types'; + +import type { ChatConfig } from '../../config/chat.config'; +import { ChatBridgeService } from './chat-bridge.service'; +import type { MatrixClient } from './matrix.client'; + +const config: ChatConfig = { + enabled: true, + baseUrl: 'https://matrix.test', + publicBaseUrl: 'https://matrix.test', + webUrl: 'https://chat.test', + serverName: 'matrix.test', + jwtSecret: 'secret', + adminToken: 'syt_whatever', +}; + +function harness(overrides: Partial = {}) { + const matrix = { + ensureRoom: jest.fn(async (alias: string) => `!${alias}:matrix.test`), + sendMessage: jest.fn( + async (_roomId: string, _body: string, _html?: string) => undefined, + ), + }; + const service = new ChatBridgeService( + { ...config, ...overrides }, + matrix as unknown as MatrixClient, + ); + return { service, matrix }; +} + +const notification = (type: NotificationType): NotifyInput => + ({ type, title: 'Booking BK-1', body: 'needs review' }) as unknown as NotifyInput; + +describe('ChatBridgeService', () => { + it('posts every notification type into #freight-alerts', async () => { + // This used to route REQUEST_SUBMITTED and CLEARANCE_REVIEW to a hardcoded + // `dept-operation` alias, but the reconcile derives dept aliases from the + // IAM position key (`edr_freight_app/opn` shaped), so nothing it created + // ever matched. The bridge made its own empty room and posted there, where + // no employee was a member. + const { service, matrix } = harness(); + + for (const type of [ + NotificationType.REQUEST_SUBMITTED, + NotificationType.CLEARANCE_REVIEW, + NotificationType.GENERIC, + ]) { + await service.bridge(notification(type)); + } + + expect(new Set(matrix.ensureRoom.mock.calls.map(([alias]) => alias))).toEqual( + new Set(['freight-alerts']), + ); + expect(matrix.sendMessage).toHaveBeenCalledTimes(3); + }); + + it('does nothing at all when chat is switched off', async () => { + const { service, matrix } = harness({ enabled: false }); + + await service.bridge(notification(NotificationType.GENERIC)); + + expect(matrix.ensureRoom).not.toHaveBeenCalled(); + expect(matrix.sendMessage).not.toHaveBeenCalled(); + }); + + it('never lets a chat failure escape into the notification that triggered it', async () => { + // Same contract as NotificationInboxService.notify(): bridging is + // best-effort and must not roll back the caller's transaction. + const { service, matrix } = harness(); + matrix.ensureRoom.mockRejectedValueOnce(new Error('Matrix POST ... -> 429')); + + await expect( + service.bridge(notification(NotificationType.GENERIC)), + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts b/apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts index 75bbe9a29..55c98b897 100644 --- a/apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts +++ b/apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts @@ -1,28 +1,11 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; import type { ConfigType } from '@nestjs/config'; -import { NotificationType, type NotifyInput } from '@edr/types'; +import type { NotifyInput } from '@edr/types'; import chatConfig from '../../config/chat.config'; +import { ALERTS_ROOM } from './chat-provisioning.service'; import { MatrixClient } from './matrix.client'; -const FALLBACK_ROOM = { alias: 'freight-alerts', name: 'Freight Alerts' }; - -/** - * Best-effort per-type routing to an existing dept room. Anything not listed - * (including GENERIC) falls through to #freight-alerts — safer than a wrong - * guess at which department a type belongs to. Extend as real usage shows - * which types actually want a dept room instead of the shared feed. - * - * `name` matters only if this bridge is the very first thing to touch that - * alias (normally the nightly/on-demand reconcile creates dept rooms first, - * with the position's real name) — ensureRoom never renames an existing - * room, so this must match what ChatProvisioningService would have used. - */ -const ROOM_FOR_TYPE: Partial> = { - [NotificationType.REQUEST_SUBMITTED]: { alias: 'dept-operation', name: 'Operation' }, - [NotificationType.CLEARANCE_REVIEW]: { alias: 'dept-operation', name: 'Operation' }, -}; - /** * Mirrors BACKOFFICE-audience notifications into chat so staff see them * without having the inbox open. Hooked once into @@ -31,6 +14,15 @@ const ROOM_FOR_TYPE: Partial${escapeHtml(input.title)}
${escapeHtml(input.body)}${ input.link ? `
${escapeHtml(input.link)}` : '' diff --git a/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.spec.ts b/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.spec.ts new file mode 100644 index 000000000..3b06cc518 --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.spec.ts @@ -0,0 +1,194 @@ +import 'reflect-metadata'; + +import type { DataSource } from 'typeorm'; + +import { ChatProvisioningService } from './chat-provisioning.service'; +import type { MatrixClient } from './matrix.client'; + +const NAA = '03f5eb9e-23a0-4413-8d98-8de4b98b1be2'; +const SUPER_ADMIN = 'f1534714-fa4a-4780-a081-05d4c1f6c25f'; +const BOT = '@edrbot:m.test'; + +interface Holder { + positionKey: string; + positionName: string; + userId: string; + userName: string; +} + +const holder = ( + userId: string, + userName: string, + positionKey: string, + positionName = positionKey, +): Holder => ({ positionKey, positionName, userId, userName }); + +/** + * `members` maps a room id to who Matrix currently reports as joined, so a + * test can put a leaver in a room and watch what the reconcile does about it. + */ +function harness(holders: Holder[], members: Record = {}) { + const matrix = { + mxidFor: jest.fn( + (userId: string, name: string) => `@${name}.${userId.slice(0, 6)}:m.test`, + ), + whoami: jest.fn(async () => BOT), + ensureUser: jest.fn(async (_mxid: string, _name?: string) => undefined), + ensureRoom: jest.fn( + async (alias: string, _name?: string, _opts?: unknown) => `!${alias}:m.test`, + ), + ensureJoined: jest.fn(async (_roomId: string, _mxid: string) => undefined), + joinedMembers: jest.fn(async (roomId: string) => members[roomId] ?? [BOT]), + kick: jest.fn(async (_roomId: string, _mxid: string, _reason: string) => undefined), + lockUser: jest.fn(async (_mxid: string) => undefined), + }; + const dataSource = { query: jest.fn(async () => holders) }; + const service = new ChatProvisioningService( + dataSource as unknown as DataSource, + matrix as unknown as MatrixClient, + ); + return { service, matrix, dataSource }; +} + +/** + * `joinUserRooms` is the only thing standing between a first sign-in and an + * empty Element — the reconcile that would otherwise fill the room list runs + * nightly. + */ +describe('ChatProvisioningService.joinUserRooms', () => { + it('creates nothing for a user holding no current position', async () => { + // Super Admin on dev: three iam.employees rows, zero employee_positions. + // Synapse still auto-registers the account on JWT login, so the only + // symptom is a working sign-in into a client with no rooms in it. + const { service, matrix } = harness([]); + + await expect(service.joinUserRooms(SUPER_ADMIN, 'Super Admin')).resolves.toBe(0); + + expect(matrix.ensureUser).not.toHaveBeenCalled(); + expect(matrix.ensureRoom).not.toHaveBeenCalled(); + expect(matrix.ensureJoined).not.toHaveBeenCalled(); + }); + + it('joins a holder to the space, #general, #freight-alerts and their dept room', async () => { + const { service, matrix } = harness([ + holder(NAA, 'naa', 'edr_freight_app/marketer', 'Marketer'), + ]); + + await expect(service.joinUserRooms(NAA, 'naa')).resolves.toBe(4); + + // The account has to exist before the admin join API will touch it — JWT + // auto-registration happens after this runs. + expect(matrix.ensureUser).toHaveBeenCalledWith('@naa.03f5eb:m.test', 'naa'); + + expect(matrix.ensureRoom.mock.calls.map(([alias]) => alias)).toEqual([ + 'edr-freight', + 'general', + 'freight-alerts', + 'dept-edr_freight_app/marketer', + ]); + + // The space itself is joined, not only the rooms under it: Element shows a + // space in the left rail only to its members, so dropping this scatters + // every dept room loose into Home. #freight-alerts is joined here too, or + // a new hire sees no bridged notification until the nightly reconcile. + expect(matrix.ensureJoined.mock.calls.map(([roomId]) => roomId)).toEqual([ + '!edr-freight:m.test', + '!general:m.test', + '!freight-alerts:m.test', + '!dept-edr_freight_app/marketer:m.test', + ]); + }); + + it('scopes the position lookup to the one user', async () => { + const { service, dataSource } = harness([]); + await service.joinUserRooms(NAA, 'naa'); + // Without the third parameter this would reconcile the whole unit on every + // click of "Open EDR Chat". + const [sql, params] = dataSource.query.mock.calls[0] as unknown as [ + string, + unknown[], + ]; + expect(sql).toContain('AND e.user_id = $3'); + expect(params).toEqual(['edr_freight', 'edr_freight_app', NAA]); + }); +}); + +describe('ChatProvisioningService.reconcile', () => { + it('aborts instead of emptying every room when the holder query returns nothing', async () => { + // Zero holders never means "every employee left at once" — it means the + // query failed, the org/unit keys drifted, or a migration is mid-flight. + // Acting on it would kick every member of every room and lock every + // account, which is exactly the outage this guard exists to prevent. + const { service, matrix } = harness([]); + + await expect(service.reconcile()).rejects.toThrow(/no current position holders/i); + + expect(matrix.kick).not.toHaveBeenCalled(); + expect(matrix.lockUser).not.toHaveBeenCalled(); + }); + + it('locks a departed member rather than deactivating them', async () => { + const leaver = '@gone.999999:m.test'; + const { service, matrix } = harness( + [holder(NAA, 'naa', 'marketer', 'Marketer')], + { + '!edr-freight:m.test': [BOT, '@naa.03f5eb:m.test', leaver], + '!general:m.test': [BOT, '@naa.03f5eb:m.test', leaver], + '!freight-alerts:m.test': [BOT, '@naa.03f5eb:m.test'], + '!dept-marketer:m.test': [BOT, '@naa.03f5eb:m.test'], + }, + ); + + const result = await service.reconcile(); + + expect(matrix.kick.mock.calls.map(([, mxid]) => mxid)).toEqual([leaver, leaver]); + // Locking is reversible; deactivation is not, and on a homeserver with no + // password login it cannot be undone at all. + expect(matrix.lockUser).toHaveBeenCalledTimes(1); + expect(matrix.lockUser).toHaveBeenCalledWith(leaver); + expect(result.locked).toBe(1); + }); + + it('does not lock someone who only moved between positions', async () => { + const naaMxid = '@naa.03f5eb:m.test'; + // naa holds `marketer` now; the room for their old position still lists them. + const { service, matrix } = harness( + [ + holder(NAA, 'naa', 'marketer', 'Marketer'), + holder('aaa04914-b7ee-47b3-9c63-4324046a26bd', 'nati', 'opn', 'Operation'), + ], + { '!dept-opn:m.test': [BOT, naaMxid, '@nati.aaa049:m.test'] }, + ); + + const result = await service.reconcile(); + + expect(matrix.kick).toHaveBeenCalledWith( + '!dept-opn:m.test', + naaMxid, + expect.any(String), + ); + // Kicked from one room, still current elsewhere — their account stays open. + expect(matrix.lockUser).not.toHaveBeenCalled(); + expect(result.locked).toBe(0); + }); + + it('refuses to empty a populated room when its desired set is empty', async () => { + // Per-room backstop for the paths the unit-level guard above cannot see. + const { service, matrix } = harness([holder(NAA, 'naa', 'marketer')], { + '!room:m.test': [BOT, '@naa.03f5eb:m.test', '@nati.aaa049:m.test'], + }); + + const diff = await ( + service as unknown as { + syncMembership: ( + roomId: string, + desired: Set, + bot: string, + ) => Promise<{ joined: number; kicked: string[] }>; + } + ).syncMembership('!room:m.test', new Set(), BOT); + + expect(diff).toEqual({ joined: 0, kicked: [] }); + expect(matrix.kick).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts b/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts index b122d8073..102cb071a 100644 --- a/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts +++ b/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts @@ -13,6 +13,11 @@ const UNIT_KEY = 'edr_freight_app'; const SPACE_ALIAS = 'edr-freight'; const GENERAL_ALIAS = 'general'; +/** Where ChatBridgeService mirrors backoffice notifications. Provisioned here, + * with every position holder in it, so bridged messages land somewhere staff + * actually are — the bridge only ever get-or-creates it as a safety net. */ +export const ALERTS_ROOM = { alias: 'freight-alerts', name: 'Freight Alerts' }; + interface PositionHolder { positionKey: string; positionName: string; @@ -24,7 +29,8 @@ export interface ReconcileResult { rooms: number; joined: number; kicked: number; - deactivated: number; + /** Departed accounts locked — reversible. See {@link MatrixClient.lockUser}. */ + locked: number; } /** @@ -55,7 +61,7 @@ export class ChatProvisioningService { const result = await this.reconcile(); this.logger.log( `Chat reconcile: ${result.rooms} room(s), ${result.joined} joined, ` + - `${result.kicked} kicked, ${result.deactivated} deactivated`, + `${result.kicked} kicked, ${result.locked} locked`, ); } catch (err) { // Never throws into the scheduler — chat provisioning must not be able @@ -113,10 +119,23 @@ export class ChatProvisioningService { const spaceId = await this.matrix.ensureRoom(SPACE_ALIAS, 'EDR Freight', { isSpace: true, }); + // The space itself, not only the rooms under it: Element lists a space in + // the left rail only for members of that space, so skipping this scatters + // every dept room loose into Home and the "EDR Freight" grouping never + // appears at all. + await this.matrix.ensureJoined(spaceId, mxid); const generalRoomId = await this.matrix.ensureRoom(GENERAL_ALIAS, 'General', { parentSpaceId: spaceId, }); await this.matrix.ensureJoined(generalRoomId, mxid); + // Without this a new hire sees no bridged notification until the nightly + // reconcile puts them in the alerts room. + const alertsRoomId = await this.matrix.ensureRoom( + ALERTS_ROOM.alias, + ALERTS_ROOM.name, + { parentSpaceId: spaceId }, + ); + await this.matrix.ensureJoined(alertsRoomId, mxid); for (const position of positions) { const roomId = await this.matrix.ensureRoom( @@ -127,10 +146,10 @@ export class ChatProvisioningService { await this.matrix.ensureJoined(roomId, mxid); } - return positions.length + 1; + return positions.length + 3; // space + general + alerts } - /** Force-joins additions, kicks+deactivates users no longer entitled anywhere. */ + /** Force-joins additions, kicks users no longer entitled to this room. */ private async syncMembership( roomId: string, desiredUserIds: Set, @@ -139,6 +158,19 @@ export class ChatProvisioningService { const current = await this.matrix.joinedMembers(roomId); const currentSet = new Set(current.filter((id) => id !== botMxid)); + // An empty desired set against a populated room is not "everyone left" — + // it is a query that failed, a key that drifted, or a migration caught + // mid-flight. Acting on it would clear the room and then lock every + // account that was in it. {@link reconcile} guards the same shape at the + // unit level; this is the per-room backstop for the paths it cannot see. + if (desiredUserIds.size === 0 && currentSet.size > 0) { + this.logger.warn( + `Refusing to empty room ${roomId}: desired membership is empty while ` + + `${currentSet.size} member(s) are joined. Left untouched.`, + ); + return { joined: 0, kicked: [] }; + } + let joined = 0; for (const userId of desiredUserIds) { if (!currentSet.has(userId)) { @@ -160,6 +192,17 @@ export class ChatProvisioningService { async reconcile(): Promise { const holders = await this.currentHolders(); + // The desired state for the whole unit. Empty means the IAM query failed, + // the org/unit keys drifted, or a migration is mid-flight — it never means + // every employee left at once. Continuing would kick every member of every + // room and lock every account, so refuse the run and keep yesterday's + // state, which is wrong at worst by a day. + if (holders.length === 0) { + throw new Error( + `Chat reconcile aborted: no current position holders for ${ORG_KEY}/${UNIT_KEY}. ` + + 'Refusing to read that as "remove everyone".', + ); + } const botMxid = await this.matrix.whoami(); const spaceId = await this.matrix.ensureRoom(SPACE_ALIAS, 'EDR Freight', { @@ -168,6 +211,11 @@ export class ChatProvisioningService { const generalRoomId = await this.matrix.ensureRoom(GENERAL_ALIAS, 'General', { parentSpaceId: spaceId, }); + const alertsRoomId = await this.matrix.ensureRoom( + ALERTS_ROOM.alias, + ALERTS_ROOM.name, + { parentSpaceId: spaceId }, + ); const allUserIds = new Set( holders.map((h) => this.matrix.mxidFor(h.userId, h.userName)), @@ -184,19 +232,32 @@ export class ChatProvisioningService { await this.matrix.ensureUser(mxid, h.userName); } - let rooms = 2; // space + general + let rooms = 3; // space + general + alerts let joined = 0; let kicked = 0; // A user kicked from anything while holding zero current positions // anywhere in the unit (allUserIds spans every position) is a full - // leaver, not just moved between positions — deactivate their account. + // leaver, not just moved between positions — lock their account. const kickedUserIds = new Set(); + // Space membership follows the org tree exactly like room membership — + // see the ensureJoined in joinUserRooms for why the space needs joining + // at all. + const spaceDiff = await this.syncMembership(spaceId, allUserIds, botMxid); + joined += spaceDiff.joined; + kicked += spaceDiff.kicked.length; + spaceDiff.kicked.forEach((uid) => kickedUserIds.add(uid)); + const generalDiff = await this.syncMembership(generalRoomId, allUserIds, botMxid); joined += generalDiff.joined; kicked += generalDiff.kicked.length; generalDiff.kicked.forEach((uid) => kickedUserIds.add(uid)); + const alertsDiff = await this.syncMembership(alertsRoomId, allUserIds, botMxid); + joined += alertsDiff.joined; + kicked += alertsDiff.kicked.length; + alertsDiff.kicked.forEach((uid) => kickedUserIds.add(uid)); + const byPosition = new Map }>(); for (const h of holders) { const entry = byPosition.get(h.positionKey) ?? { @@ -219,19 +280,19 @@ export class ChatProvisioningService { diff.kicked.forEach((uid) => kickedUserIds.add(uid)); } - let deactivated = 0; + let locked = 0; for (const userId of kickedUserIds) { if (allUserIds.has(userId)) continue; // moved position, still current elsewhere try { - await this.matrix.deactivateUser(userId); - deactivated += 1; + await this.matrix.lockUser(userId); + locked += 1; } catch (err) { this.logger.warn( - `Failed to deactivate departed user ${userId}: ${(err as Error).message}`, + `Failed to lock departed user ${userId}: ${(err as Error).message}`, ); } } - return { rooms, joined, kicked, deactivated }; + return { rooms, joined, kicked, locked }; } } diff --git a/apps/edr-freight-api/src/modules/chat/chat.module.ts b/apps/edr-freight-api/src/modules/chat/chat.module.ts index 8df827339..db2e9c44c 100644 --- a/apps/edr-freight-api/src/modules/chat/chat.module.ts +++ b/apps/edr-freight-api/src/modules/chat/chat.module.ts @@ -11,6 +11,8 @@ import { MatrixClient } from './matrix.client'; providers: [MatrixClient, ChatSsoService, ChatProvisioningService, ChatBridgeService], // ChatBridgeService: consumed by NotificationInboxModule to mirror // BACKOFFICE notifications into chat — see notification-inbox.module.ts. - exports: [ChatBridgeService], + // MatrixClient: HealthModule's readiness probe reports whether + // MATRIX_ADMIN_TOKEN really carries server-admin rights. + exports: [ChatBridgeService, MatrixClient], }) export class ChatModule {} diff --git a/apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts b/apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts index ea5faa978..22cf8afd8 100644 --- a/apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts +++ b/apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts @@ -1,4 +1,5 @@ -import { chatLocalpart } from './matrix.client'; +import type { ChatConfig } from '../../config/chat.config'; +import { MatrixClient, chatLocalpart } from './matrix.client'; describe('chatLocalpart', () => { it('reads from the name, not the id', () => { @@ -33,3 +34,180 @@ describe('chatLocalpart', () => { } }); }); + +const config: ChatConfig = { + enabled: true, + baseUrl: 'https://matrix.test', + publicBaseUrl: 'https://matrix.test', + webUrl: 'https://chat.test', + serverName: 'matrix.test', + jwtSecret: 'secret', + adminToken: 'syt_whatever', +}; + +type FetchFn = typeof globalThis.fetch; + +/** Just enough of a Response for {@link MatrixClient}'s fetch wrappers. */ +function response(status: number, body: unknown) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + text: async () => JSON.stringify(body), + }; +} + +const realFetch: FetchFn = globalThis.fetch; +const fetchMock = jest.fn(); + +beforeEach(() => { + fetchMock.mockReset(); + globalThis.fetch = fetchMock as unknown as FetchFn; +}); + +afterAll(() => { + globalThis.fetch = realFetch; +}); + +describe('MatrixClient.verifyServerAdmin', () => { + it('accepts a token that can actually call the Synapse admin API', async () => { + fetchMock + .mockResolvedValueOnce(response(200, { user_id: '@edrbot:matrix.test' })) + .mockResolvedValueOnce(response(200, { users: [], total: 1 })); + + const check = await new MatrixClient(config).verifyServerAdmin(); + + expect(check).toEqual({ ok: true, actingAs: '@edrbot:matrix.test' }); + // The admin ping is the check. If this ever regresses to whoami alone, + // the assertion below is what catches it. + expect(String(fetchMock.mock.calls[1][0])).toContain('/_synapse/admin/'); + }); + + it('rejects a valid token that is not a server admin', async () => { + // The dev outage, exactly: MATRIX_ADMIN_TOKEN held @super-admin's own + // token. whoami answered 200, every /_synapse/admin call answered 403, + // ensureUser threw, ChatSsoService swallowed it, and every employee got a + // working sign-in into an Element with no rooms in it. + fetchMock + .mockResolvedValueOnce( + response(200, { user_id: '@super-admin.f15347:matrix.test' }), + ) + .mockResolvedValueOnce( + response(403, { + errcode: 'M_FORBIDDEN', + error: 'You are not a server admin', + }), + ); + + const check = await new MatrixClient(config).verifyServerAdmin(); + + expect(check.ok).toBe(false); + // Naming the account the token belongs to is the whole point — it is what + // turns "chat is broken" into "wrong token in the env". + expect(check.actingAs).toBe('@super-admin.f15347:matrix.test'); + expect(check.error).toContain('403'); + }); + + it('rejects a token that is not valid at all', async () => { + fetchMock.mockResolvedValueOnce( + response(401, { errcode: 'M_UNKNOWN_TOKEN', error: 'Invalid access token' }), + ); + + const check = await new MatrixClient(config).verifyServerAdmin(); + + expect(check.ok).toBe(false); + expect(check.actingAs).toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(1); // no point pinging admin after this + }); +}); + +describe('MatrixClient.ensureUser', () => { + it('lifts the lock on a returning employee', async () => { + // A previous reconcile locked them as a leaver. Force-joining them back + // into rooms while they still cannot log in is a silent half-restore. + fetchMock + .mockResolvedValueOnce( + response(200, { name: '@naa.03f5eb:matrix.test', locked: true }), + ) + .mockResolvedValueOnce(response(200, {})); + + await new MatrixClient(config).ensureUser('@naa.03f5eb:matrix.test', 'naa'); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const [url, init] = fetchMock.mock.calls[1] as [string, { body: string }]; + expect(String(url)).toContain('/_synapse/admin/v2/users/'); + expect(JSON.parse(init.body)).toEqual({ locked: false }); + }); + + it('leaves an account that is not locked alone', async () => { + fetchMock.mockResolvedValueOnce( + response(200, { name: '@naa.03f5eb:matrix.test', locked: false }), + ); + + await new MatrixClient(config).ensureUser('@naa.03f5eb:matrix.test', 'naa'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); + +describe('MatrixClient rate limiting', () => { + it('retries a 429 after the delay Synapse asks for', async () => { + // The dev outage: a reconcile is a burst of writes, Synapse throttled an + // m.space.child PUT, and one un-retried 429 threw the whole run away. + fetchMock + .mockResolvedValueOnce(response(200, { user_id: '@edrbot:matrix.test' })) + .mockResolvedValueOnce( + response(429, { + errcode: 'M_LIMIT_EXCEEDED', + error: 'Too Many Requests', + retry_after_ms: 1, + }), + ) + .mockResolvedValueOnce(response(200, { users: [] })); + + const check = await new MatrixClient(config).verifyServerAdmin(); + + expect(check.ok).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('gives up rather than hanging on a homeserver that only ever 429s', async () => { + fetchMock.mockResolvedValue( + response(429, { errcode: 'M_LIMIT_EXCEEDED', retry_after_ms: 1 }), + ); + + const check = await new MatrixClient(config).verifyServerAdmin(); + + expect(check.ok).toBe(false); + expect(check.error).toContain('429'); + }); +}); + +describe('MatrixClient.adminCheck', () => { + it('does not re-hit Synapse on every readiness probe', async () => { + fetchMock + .mockResolvedValueOnce(response(200, { user_id: '@edrbot:matrix.test' })) + .mockResolvedValueOnce(response(200, { users: [] })); + + const client = new MatrixClient(config); + const first = await client.adminCheck(); + const second = await client.adminCheck(); + + expect(second).toBe(first); + expect(fetchMock).toHaveBeenCalledTimes(2); // whoami + admin ping, once + }); + + it('re-checks when forced, so boot never reads a stale verdict', async () => { + fetchMock + .mockResolvedValueOnce(response(200, { user_id: '@edrbot:matrix.test' })) + .mockResolvedValueOnce(response(200, { users: [] })) + .mockResolvedValueOnce(response(200, { user_id: '@edrbot:matrix.test' })) + .mockResolvedValueOnce(response(200, { users: [] })); + + const client = new MatrixClient(config); + await client.adminCheck(); + await client.adminCheck(true); + + expect(fetchMock).toHaveBeenCalledTimes(4); + }); +}); diff --git a/apps/edr-freight-api/src/modules/chat/matrix.client.ts b/apps/edr-freight-api/src/modules/chat/matrix.client.ts index 1cd09ac33..4da85b339 100644 --- a/apps/edr-freight-api/src/modules/chat/matrix.client.ts +++ b/apps/edr-freight-api/src/modules/chat/matrix.client.ts @@ -1,4 +1,5 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; +import type { OnApplicationBootstrap } from '@nestjs/common'; import type { ConfigType } from '@nestjs/config'; import chatConfig from '../../config/chat.config'; @@ -40,8 +41,27 @@ export function chatLocalpart(userId: string, displayName: string): string { return `${slug || 'user'}.${userId.replace(/-/g, '').slice(0, 6)}`; } +/** Result of {@link MatrixClient.verifyServerAdmin}. */ +export interface AdminCheck { + ok: boolean; + /** Who MATRIX_ADMIN_TOKEN belongs to — present whenever the token is valid + * at all, including when it is valid but carries no admin rights. */ + actingAs?: string; + error?: string; +} + @Injectable() -export class MatrixClient { +export class MatrixClient implements OnApplicationBootstrap { + private readonly logger = new Logger(MatrixClient.name); + + /** The token is a deploy-time fact and the readiness probe runs every few + * seconds, so {@link adminCheck} memoises for this long. */ + private static readonly ADMIN_CHECK_TTL_MS = 5 * 60_000; + /** Enough to ride out Synapse's limiter; short enough that a genuinely + * wedged homeserver still fails the run rather than hanging it. */ + private static readonly MAX_RATE_LIMIT_RETRIES = 5; + private adminCheckCache?: { at: number; result: AdminCheck }; + constructor( @Inject(chatConfig.KEY) private readonly config: ConfigType, @@ -73,13 +93,48 @@ export class MatrixClient { return this.config.serverName; } + /** MATRIX_ENABLED — read by the readiness probe to tell "off" from "broken". */ + get enabled(): boolean { + return this.config.enabled; + } + + /** + * Synapse answers a burst of writes with 429 + `retry_after_ms`, and a + * reconcile is nothing but a burst of writes — one run creates the space, + * #general and a room per position, then force-joins every holder into each. + * The first run against dev tripped the limiter on an `m.space.child` PUT, + * and because nothing retried, that single 429 threw the whole reconcile + * away mid-flight. On the sign-in path ChatSsoService swallows the throw, so + * the only visible symptom was an empty Element. + * + * Honour the delay Synapse asks for rather than guessing at one. + */ + private async fetchWithRetry( + url: string, + init: Parameters[1], + ): Promise>> { + for (let attempt = 0; ; attempt++) { + const res = await fetch(url, init); + if (res.status !== 429 || attempt >= MatrixClient.MAX_RATE_LIMIT_RETRIES) { + return res; + } + // Body is discarded either way — this response is being retried. + const body = (await res.json().catch(() => ({}))) as { + retry_after_ms?: number; + }; + await new Promise((resolve) => + setTimeout(resolve, (Number(body.retry_after_ms) || 1000) + 100), + ); + } + } + private async request( method: string, path: string, body?: unknown, token: string = this.config.adminToken, ): Promise { - const res = await fetch(`${this.config.baseUrl}${path}`, { + const res = await this.fetchWithRetry(`${this.config.baseUrl}${path}`, { method, headers: { 'Content-Type': 'application/json', @@ -103,7 +158,7 @@ export class MatrixClient { path: string, body: unknown, ): Promise { - const res = await fetch(`${this.config.baseUrl}${path}`, { + const res = await this.fetchWithRetry(`${this.config.baseUrl}${path}`, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -123,7 +178,7 @@ export class MatrixClient { path: string, token?: string, ): Promise { - const res = await fetch(`${this.config.baseUrl}${path}`, { + const res = await this.fetchWithRetry(`${this.config.baseUrl}${path}`, { method, headers: { Authorization: `Bearer ${token ?? this.config.adminToken}` }, }); @@ -157,6 +212,68 @@ export class MatrixClient { return res.user_id; } + /** + * Is MATRIX_ADMIN_TOKEN actually a *server admin* token? + * + * `whoami` cannot answer this: it returns 200 for any valid user token at + * all. Dev shipped with MATRIX_ADMIN_TOKEN holding an ordinary staff + * account's token — whoami said 200, every `/_synapse/admin/*` call said + * 403 "You are not a server admin", `ensureUser` threw, ChatSsoService + * swallowed it (by design — a failed room join must not deny anyone a + * sign-in link), and every employee got a working sign-in into a client + * with no rooms in it. Nothing else in the system noticed. + * + * So this pings an endpoint only a server admin may call, and reports who + * the token belongs to — the one fact that makes the mix-up obvious. + */ + async verifyServerAdmin(): Promise { + let actingAs: string | undefined; + try { + actingAs = await this.whoami(); + await this.request('GET', '/_synapse/admin/v2/users?limit=1'); + return { ok: true, actingAs }; + } catch (err) { + return { ok: false, actingAs, error: (err as Error).message }; + } + } + + /** {@link verifyServerAdmin}, memoised for {@link ADMIN_CHECK_TTL_MS}. */ + async adminCheck(force = false): Promise { + const cached = this.adminCheckCache; + if ( + !force && + cached && + Date.now() - cached.at < MatrixClient.ADMIN_CHECK_TTL_MS + ) { + return cached.result; + } + const result = await this.verifyServerAdmin(); + this.adminCheckCache = { at: Date.now(), result }; + return result; + } + + /** + * Fail loud at boot instead of silently on every sign-in. Logged, never + * thrown: chat provisioning must not be able to stop the API from starting, + * the same contract the reconcile cron and the notification bridge hold to. + */ + async onApplicationBootstrap(): Promise { + if (!this.config.enabled) return; + const check = await this.adminCheck(true); + if (check.ok) { + this.logger.log( + `MATRIX_ADMIN_TOKEN verified — server admin as ${check.actingAs}`, + ); + return; + } + this.logger.error( + 'MATRIX_ADMIN_TOKEN is not a server-admin token' + + (check.actingAs ? ` (it belongs to ${check.actingAs})` : '') + + `: ${check.error}. Chat provisioning will create no rooms, and every ` + + 'employee who opens chat will land in an empty Element.', + ); + } + /** Currently-joined user ids for a room (not full member-event state). */ async joinedMembers(roomId: string): Promise { const res = await this.request<{ joined: Record }>( @@ -249,11 +366,18 @@ export class MatrixClient { * ("User not found") on an account that doesn't exist yet. */ async ensureUser(userId: string, displayName?: string): Promise { - const existing = await this.requestOrNull<{ name: string }>( + const existing = await this.requestOrNull<{ name: string; locked?: boolean }>( 'GET', `/_synapse/admin/v2/users/${encodeURIComponent(userId)}`, ); - if (existing) return; + if (existing) { + // A returning employee is still locked from the reconcile that saw them + // leave. Force-joining them into rooms while they cannot log in is a + // silent half-restore, and this is the one call that already knows the + // flag — so undo it here rather than making the caller ask again. + if (existing.locked) await this.setLocked(userId, false); + return; + } await this.request( 'PUT', `/_synapse/admin/v2/users/${encodeURIComponent(userId)}`, @@ -293,12 +417,30 @@ export class MatrixClient { ); } - /** Deactivating (rather than just kicking) a leaver's account revokes all their sessions. */ - deactivateUser(userId: string): Promise { + /** + * Lock a departed employee out of chat — reversible, unlike deactivation. + * + * This used to call `/_synapse/admin/v1/deactivate`. That revokes sessions + * the same way but cannot be undone in any useful sense on this deployment: + * reactivation wants a password, and `password_config.enabled: false` means + * there is none to set. Room memberships do not come back either. One bad + * reconcile — a half-applied IAM migration, a renamed org key — would have + * destroyed every staff account that way, permanently. + * + * Locking blocks exactly the same access (Synapse rejects the account's + * tokens with M_USER_LOCKED and refuses new logins) and is undone with a + * single PUT — see {@link ensureUser}, which lifts it automatically when + * someone comes back. + */ + lockUser(userId: string): Promise { + return this.setLocked(userId, true); + } + + private setLocked(userId: string, locked: boolean): Promise { return this.request( - 'POST', - `/_synapse/admin/v1/deactivate/${encodeURIComponent(userId)}`, - { erase: false }, + 'PUT', + `/_synapse/admin/v2/users/${encodeURIComponent(userId)}`, + { locked }, ); } 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 0911b0fcd..733b6e441 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, @@ -268,11 +270,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({ @@ -329,6 +362,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/container-management/containers.service.ts b/apps/edr-freight-api/src/modules/container-management/containers.service.ts index 2f9d984e6..61cdacd79 100644 --- a/apps/edr-freight-api/src/modules/container-management/containers.service.ts +++ b/apps/edr-freight-api/src/modules/container-management/containers.service.ts @@ -8,6 +8,8 @@ import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto'; import { Container } from './entities/container.entity'; import { Wagon } from '../wagons/entities/wagon.entity'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; +import { WagonEventType } from '@edr/types'; +import { WagonHistoryService } from '../wagon-history/wagon-history.service'; @Injectable() export class ContainersService { @@ -19,6 +21,7 @@ export class ContainersService { @InjectRepository(ContainerType) private readonly containerTypeRepo: Repository, private readonly dataSource: DataSource, + private readonly wagonHistory: WagonHistoryService, ) {} async create(dto: CreateContainerDto): Promise { @@ -150,7 +153,16 @@ export class ContainersService { // Placing a container on a wagon does not make it AVAILABLE. The status enum // (AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED) has no ASSIGNED/ON_WAGON // state, so leave the existing status unchanged rather than forcing AVAILABLE. - return containerRepo.save(container); + const saved = await containerRepo.save(container); + await this.wagonHistory.record(manager, { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.ContainerPlaced, + toYardId: wagon.currentYardId ?? null, + toValue: container.containerNumber, + metadata: { containerId: container.id, position }, + }); + return saved; }); } @@ -159,9 +171,23 @@ export class ContainersService { if (container.status === 'LOADED') { throw new ConflictException('Cannot unassign a loaded container'); } + const previousWagonId = container.wagonId; + const previousPosition = container.position ?? null; container.wagonId = null; container.position = null; container.status = 'AVAILABLE'; - return this.containerRepo.save(container); + const saved = await this.containerRepo.save(container); + if (previousWagonId) { + const wagon = await this.wagonRepo.findOne({ where: { id: previousWagonId } }); + await this.wagonHistory.record(null, { + wagonId: previousWagonId, + wagonNumber: wagon?.wagonNumber ?? null, + type: WagonEventType.ContainerRemoved, + fromYardId: wagon?.currentYardId ?? null, + fromValue: container.containerNumber, + metadata: { containerId: container.id, position: previousPosition }, + }); + } + return saved; } } 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 f5e00f503..88a2175b9 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 @@ -2250,7 +2250,9 @@ export class ContractBookingService { unitRepo.create({ bookingContainerId: containerRow.id, containerNumber: unit.containerNumber, - sealNumber: unit.sealNumber ?? null, + // Legacy units recovered by the remainder placement can still + // arrive sealless — keep those null rather than empty-string. + sealNumber: unit.sealNumber?.trim() || null, vgmTons: unit.vgmTons, isHazardous: unit.isHazardous ?? false, isReefer: unit.isReefer ?? false, 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 0592dafc4..b1dabfa16 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 @@ -7,6 +7,7 @@ import { IsEmail, IsIn, IsInt, + IsNotEmpty, IsNumber, IsOptional, IsString, @@ -32,10 +33,12 @@ export class CreateContainerUnitDto { }) containerNumber!: string; - @ApiPropertyOptional() - @IsOptional() + @ApiProperty({ description: 'Seal number — required on every container, import and export alike.' }) @IsString() - sealNumber?: string; + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsNotEmpty({ message: 'sealNumber is required' }) + @MaxLength(64) + sealNumber!: string; @ApiProperty({ description: 'VGM in tons', minimum: 0 }) @IsNumber() diff --git a/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.ts index 875349b6a..e8c111b62 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.ts @@ -134,6 +134,7 @@ export class EimsBulkRegistrationService { region: invoice.company?.region, zone: invoice.company?.zone, woreda: invoice.company?.woreda, + kebele: invoice.company?.kebele, }); return { invoice, documentType, relatedDocument, buyerGeo }; }); diff --git a/apps/edr-freight-api/src/modules/eims/eims-client.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-client.service.spec.ts new file mode 100644 index 000000000..3d68e180e --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-client.service.spec.ts @@ -0,0 +1,168 @@ +import { HttpService } from "@nestjs/axios"; +import { Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { AxiosError, AxiosHeaders } from "axios"; +import { of, throwError } from "rxjs"; + +import { EimsConfig } from "../../config/eims.config"; +import { EimsAuthService } from "./eims-auth.service"; +import { EimsClientService } from "./eims-client.service"; +import { EimsSignerService } from "./eims-signer.service"; +import { eimsConfig } from "./eims-test-fixtures"; + +const API_KEY = "super-secret-apikey"; +const CLIENT_SECRET = "super-secret-value"; +const TOKEN = "access-token-value"; + +/** Stub signer: the real signing path has its own spec and needs no key material here. */ +const signer = { + signRequest: (request: T) => ({ request, signature: "SIGNATURE", certificate: "CERTIFICATE" }), +} as unknown as EimsSignerService; + +const build = (post: jest.Mock, config: EimsConfig = eimsConfig(), token: string = TOKEN) => + new EimsClientService( + { post } as unknown as HttpService, + { get: () => config } as unknown as ConfigService, + { + getValidAccessToken: jest.fn().mockResolvedValue(token), + invalidate: jest.fn(), + } as unknown as EimsAuthService, + signer, + ); + +const ok = (data: unknown = { statusCode: 200, body: { Irn: "irn-echoed" } }) => + jest.fn().mockReturnValue(of({ data })); + +const axiosErr = (status: number, data: unknown) => + new AxiosError("Request failed", undefined, undefined, undefined, { + status, + statusText: "", + data, + headers: new AxiosHeaders(), + config: { headers: new AxiosHeaders() }, + }); + +/** `post(url, body, config)` — the config argument every assertion below reads. */ +const sentConfig = (post: jest.Mock, call = 0) => post.mock.calls[call][2]; +const sentBody = (post: jest.Mock, call = 0) => post.mock.calls[call][1]; + +describe("EimsClientService transport", () => { + const protectedHeaders = { + "Content-Type": "application/json", + Authorization: `Bearer ${TOKEN}`, + apikey: API_KEY, + }; + + it.each([ + ["verify", "/v1/verify", { irn: "irn-1" }], + ["sales receipt", "/v1/receipt/sales", { receipt: "sales" }], + ["withholding receipt", "/v1/receipt/withholding", { receipt: "withholding" }], + ["cancel", "/v1/cancel", { Irn: "irn-1" }], + ["bulk cancel", "/v1/bulkCancel", [{ Irn: "irn-1" }]], + ])("authenticates the raw %s endpoint without changing its body", async (_name, path, body) => { + const post = ok(); + await build(post).postBearer(path, body); + + expect(sentConfig(post).headers).toEqual(protectedHeaders); + expect(sentBody(post)).toBe(body); + }); + + it.each([ + ["invoice", { DocumentDetails: { Type: "INV" } }], + ["credit memo", { DocumentDetails: { Type: "CRE" } }], + ["debit memo", { DocumentDetails: { Type: "DEB" } }], + ])("authenticates and signs a %s registration", async (_name, request) => { + const post = ok({ statusCode: 200, body: { irn: "irn-1" } }); + await build(post).postSigned("/v1/register", request); + + expect(sentConfig(post).headers).toEqual(protectedHeaders); + expect(JSON.parse(sentBody(post) as string)).toEqual({ + request, + signature: "SIGNATURE", + certificate: "CERTIFICATE", + }); + }); + + it("authenticates bulk registration through the same signed path", async () => { + const post = ok({ conversationId: "conversation-1", status: 202 }); + const request = [{ DocumentDetails: { Type: "INV" } }]; + await build(post).postSigned("/v1/bulkRegister", request); + + expect(sentConfig(post).headers).toEqual(protectedHeaders); + }); + + it("wraps a signed call in the {request,signature,certificate} envelope", async () => { + const post = ok({ statusCode: 200, body: { irn: "irn-1" } }); + await build(post).postSigned("/v1/register", { Invoice: 1 }); + + expect(JSON.parse(sentBody(post) as string)).toEqual({ + request: { Invoice: 1 }, + signature: "SIGNATURE", + certificate: "CERTIFICATE", + }); + }); + + it("leaves an unsigned body verbatim", async () => { + const post = ok(); + await build(post).postBearer("/v1/cancel", { Irn: "irn-1" }); + + // Raw object, not the JSON string `toSignedBody` produces. + expect(sentBody(post)).toEqual({ Irn: "irn-1" }); + }); + + it("re-authenticates a raw verify call through the one 401 retry without changing its body", async () => { + const post = jest + .fn() + .mockReturnValueOnce(throwError(() => axiosErr(401, { message: "expired" }))) + .mockReturnValueOnce(of({ data: { statusCode: 200, body: { Irn: "irn-1" } } })); + + await build(post).postBearer("/v1/verify", { irn: "irn-1" }); + + expect(post).toHaveBeenCalledTimes(2); + expect(sentConfig(post, 1).headers.Authorization).toBe(`Bearer ${TOKEN}`); + expect(sentBody(post, 1)).toEqual({ irn: "irn-1" }); + }); + + it("never leaks the api key, bearer token or client secret into a thrown failure", async () => { + const logError = jest.spyOn(Logger.prototype, "error").mockImplementation(() => undefined); + const post = jest.fn().mockReturnValue( + throwError(() => + // A gateway rejection may echo request data; redaction must remove it before logging. + axiosErr(400, { + message: "GATEWAY ERROR", + code: "4001", + details: [ + { field: "certificate", errorMessage: "must not be null" }, + { field: "signature", errorMessage: "must not be null" }, + { field: "request", errorMessage: "must not be null" }, + ], + // An echoed request is exactly what redaction has to drop. + request: { apikey: API_KEY, clientSecret: CLIENT_SECRET }, + }), + ), + ); + + const error: Error = await build(post) + .postBearer("/v1/verify", { irn: "irn-1" }) + .then(() => { + throw new Error("expected the call to reject"); + }) + .catch((err: Error) => err); + + const serialized = JSON.stringify({ + message: error.message, + response: (error as { getResponse?: () => unknown }).getResponse?.(), + details: (error as { details?: unknown }).details, + }); + expect(serialized).not.toContain(API_KEY); + expect(serialized).not.toContain(CLIENT_SECRET); + expect(serialized).not.toContain(TOKEN); + const serializedLogs = JSON.stringify(logError.mock.calls); + expect(serializedLogs).not.toContain(API_KEY); + expect(serializedLogs).not.toContain(CLIENT_SECRET); + expect(serializedLogs).not.toContain(TOKEN); + // The gateway's own reporting still survives redaction. + expect(error.message).toContain("4001"); + logError.mockRestore(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-client.service.ts b/apps/edr-freight-api/src/modules/eims/eims-client.service.ts index 610647b1a..4f2455c85 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-client.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-client.service.ts @@ -8,10 +8,10 @@ import { EimsSignerService, toSignedBody } from "./eims-signer.service"; import { toEimsApiException } from "./eims.errors"; /** - * Foundation for EIMS's bearer-authenticated endpoints (`/v1/register`, `/v1/verify`, …). + * Foundation for EIMS's authenticated endpoints (`/v1/register`, `/v1/verify`, …). * * Login is not routed through here: `/auth/login` carries no bearer token and lives in - * `EimsAuthService`. Nothing calls `postSigned` yet — invoice registration is a later phase. + * `EimsAuthService`. */ @Injectable() export class EimsClientService { @@ -29,7 +29,8 @@ export class EimsClientService { } /** - * Sign `request`, POST it to `path` with a valid bearer token, and return the parsed response. + * Sign `request`, POST it to `path` with the shared protected-endpoint headers, and return the + * parsed response. * A 401 invalidates the cached token and retries exactly once. */ async postSigned(path: string, request: TRequest): Promise { @@ -37,12 +38,8 @@ export class EimsClientService { } /** - * POST `request` verbatim — bearer-authenticated but **not** wrapped in a signed envelope. - * - * `/v1/verify` is the only endpoint observed to work this way: the supplied collection sends a - * raw `{"irn":"…"}` body with no `signature`/`certificate` siblings. Kept as its own entry point - * so that if the live gateway turns out to require signing after all, exactly one call site - * changes — `postSigned` is already the alternative. + * POST `request` verbatim with the shared protected-endpoint headers, but **not** wrapped in a + * signed envelope. This is the wire contract for verify, cancel and receipt calls. */ async postBearer(path: string, request: TRequest): Promise { return this.send(path, request, false, false); @@ -61,7 +58,11 @@ export class EimsClientService { try { const res = await firstValueFrom( this.http.post(`${cfg.baseUrl}${path}`, body, { - headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + apikey: cfg.apiKey, + }, timeout: cfg.httpTimeoutMs, }), ); diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts index 2fb83dc72..030cf2fe5 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts @@ -3,6 +3,7 @@ import { EimsConfig } from "../../config/eims.config"; import { MorGeoCodes } from "../../config/mor-location.resolver"; import { EimsSessionContext } from "./eims-auth.service"; import { + EimsLineTax, EimsMapperContext, EimsMapperLine, EimsSellerDetails, @@ -172,12 +173,35 @@ export interface EimsContextInput { relatedDocument?: string | null; } +/** + * Tax treatment of one charge type: its per-`chargeType` override when one is configured + * (validated symmetric in `assertChargeTypeOverrides`), else the single invoice-wide default. + * + * Exported because the printed tax document has to state the same Tax Code, Excise and Discount + * per line that was filed with MoR, and it must be able to do so without a live EIMS session — + * `buildEimsContext` needs a system number from an access token, printing does not. + */ +export function resolveLineTax(config: EimsConfig, chargeType: string): EimsLineTax { + const { invoice } = config; + return { + code: invoice.taxCodeByChargeType[chargeType] ?? invoice.taxCode, + ratePercent: + chargeType in invoice.taxRateByChargeType + ? Number(invoice.taxRateByChargeType[chargeType]) + : invoice.taxRatePercent!, + exciseTaxValue: + chargeType in invoice.exciseByChargeType + ? Number(invoice.exciseByChargeType[chargeType]) + : (invoice.exciseTaxValue ?? 0), + discount: + chargeType in invoice.discountByChargeType + ? Number(invoice.discountByChargeType[chargeType]) + : 0, + }; +} + export function buildEimsContext(config: EimsConfig, input: EimsContextInput): EimsMapperContext { const { invoice } = config; - // Validated by assertEimsInvoiceConfig; the non-null assertions below are safe after that call. - const taxCode = invoice.taxCode; - const ratePercent = invoice.taxRatePercent!; - const exciseTaxValue = invoice.exciseTaxValue ?? 0; return { systemNumber: input.session.systemNumber, @@ -191,23 +215,7 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E payment: { mode: invoice.paymentMode, term: invoice.paymentTerm }, // Per-`chargeType` override when one is configured (validated symmetric in // assertChargeTypeOverrides), else the single invoice-wide default. - taxForLine: (line: EimsMapperLine) => { - const { chargeType } = line; - const code = invoice.taxCodeByChargeType[chargeType] ?? taxCode; - const rate = - chargeType in invoice.taxRateByChargeType - ? Number(invoice.taxRateByChargeType[chargeType]) - : ratePercent; - const excise = - chargeType in invoice.exciseByChargeType - ? Number(invoice.exciseByChargeType[chargeType]) - : exciseTaxValue; - const discount = - chargeType in invoice.discountByChargeType - ? Number(invoice.discountByChargeType[chargeType]) - : 0; - return { code, ratePercent: rate, exciseTaxValue: excise, discount }; - }, + taxForLine: (line: EimsMapperLine) => resolveLineTax(config, line.chargeType), natureOfSupplies: invoice.natureOfSupplies, unitDefault: invoice.unitDefault, incomeWithholdValue: invoice.incomeWithholdValue!, diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts index 69a4d5ffd..34e654e3b 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts @@ -759,16 +759,15 @@ describe("EimsInvoiceRegistrationService staff alerting", () => { }); describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => { - it("verifies the stored IRN over the unsigned bearer transport", async () => { + it("verifies the stored IRN as an unchanged raw body", async () => { const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]); - const postSigned = jest.fn(); const postBearer = jest.fn().mockResolvedValue(verifyResponse()); + const postSigned = jest.fn(); const result = await build(db, postSigned, config(), postBearer).verifyInvoiceWithEims( INVOICE_ID, ); - // Lowercase `irn`, raw body — not a signed envelope. `postSigned` must stay untouched. expect(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN }); expect(postSigned).not.toHaveBeenCalled(); expect(result.body).toMatchObject({ Irn: IRN }); @@ -792,6 +791,27 @@ describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => { ).rejects.toThrow(/no EIMS IRN to verify/); expect(postBearer).not.toHaveBeenCalled(); }); + + it("leaves a filed invoice and the IRN chain untouched when the gateway rejects the verify", async () => { + const db = new FakeDb([ + invoiceRow({ eimsIrn: IRN, eimsStatus: EimsInvoiceStatus.Registered }), + ]); + const before = { ...db.invoices.get(INVOICE_ID)! }; + const stateBefore = { ...db.state! }; + // The live failure this guards: `GATEWAY ERROR code=4001`, a transport fault on a document + // that is already registered. Verification is a read — a failed read must never downgrade the + // registration or move the counter. + const postBearer = jest + .fn() + .mockRejectedValue(new EimsApiException("SCHEMA_VALIDATION", "GATEWAY ERROR code=4001", 400)); + + await expect( + build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID), + ).rejects.toThrow(/4001/); + + expect(db.invoices.get(INVOICE_ID)).toEqual(before); + expect(db.state).toEqual(stateBefore); + }); }); describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { @@ -886,15 +906,15 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { it("discards the attempt, leaving the chain where it was", async () => { const db = blocked(); - const postBearer = jest.fn(); + const postSigned = jest.fn(); - const view = await build(db, jest.fn(), config(), postBearer).resolveEimsRegistration( + const view = await build(db, postSigned, config(), jest.fn()).resolveEimsRegistration( INVOICE_ID, { discard: true }, ); expect(view).toMatchObject({ eimsStatus: EimsInvoiceStatus.Failed, eimsIrn: null }); - expect(postBearer).not.toHaveBeenCalled(); // nothing to confirm + expect(postSigned).not.toHaveBeenCalled(); // nothing to confirm expect(db.state).toMatchObject({ previousIrn: null, inFlightInvoiceId: null, @@ -908,10 +928,10 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { OTHER_INVOICE_ID, invoiceRow({ id: OTHER_INVOICE_ID, eimsDocumentNumber: "6" }), ); - const postBearer = jest.fn().mockResolvedValue(verifyResponse()); + const postSigned = jest.fn().mockResolvedValue(verifyResponse()); await expect( - build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(OTHER_INVOICE_ID, { + build(db, postSigned, config(), jest.fn()).resolveEimsRegistration(OTHER_INVOICE_ID, { irn: IRN, }), ).rejects.toThrow(/in-flight EIMS submission is invoice/); diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts index d5c987779..246914e1c 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts @@ -126,6 +126,7 @@ export class EimsInvoiceRegistrationService { region: invoice.company?.region, zone: invoice.company?.zone, woreda: invoice.company?.woreda, + kebele: invoice.company?.kebele, }); // Authenticate before reserving: the source system comes from the token, and the state row is @@ -213,7 +214,8 @@ export class EimsInvoiceRegistrationService { * compared — the supplied collection's own fixture uses different example values on each side, * so equality there would assert a property of the mock rather than of the gateway. * - * Bearer-authenticated but unsigned, via `postBearer` — see that method for why. + * Raw, via `postBearer`: verification accepts exactly `{"irn":"…"}` and relies on the shared + * transport for the bearer token and API-key header. It must not be signed or wrapped. */ private async queryVerify(irn: string): Promise { const response = await this.client.postBearer( diff --git a/apps/edr-freight-api/src/modules/eims/eims-receipt-document.mapper.ts b/apps/edr-freight-api/src/modules/eims/eims-receipt-document.mapper.ts index 841bfcc54..0d4a0fd78 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-receipt-document.mapper.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-receipt-document.mapper.ts @@ -1,8 +1,11 @@ +import { EimsConfig } from "../../config/eims.config"; import { Invoice } from "../billing/entities/invoice.entity"; import { InvoiceDocumentModel, + MorPartyDetails, pngDataUrl, } from "../billing/documents/invoice-document.service"; +import { buildEimsSeller } from "./eims-invoice-context"; import { EimsReceipt, EimsReceiptStatus } from "./entities/eims-receipt.entity"; import { EimsSalesReceiptRequest, EimsWithholdReceiptRequest } from "./eims-receipt.types"; @@ -20,7 +23,11 @@ import { EimsSalesReceiptRequest, EimsWithholdReceiptRequest } from "./eims-rece * would read as a genuine tax document. Callers (`EimsReceiptService.document`) let this throw * surface as a 400 — there is nothing sensible to render instead. */ -export function toReceiptDocumentModel(receipt: EimsReceipt, invoice: Invoice): InvoiceDocumentModel { +export function toReceiptDocumentModel( + receipt: EimsReceipt, + invoice: Invoice, + config?: EimsConfig, +): InvoiceDocumentModel { if (receipt.status !== EimsReceiptStatus.Registered) { throw new Error( `Receipt ${receipt.receiptNumber} is ${receipt.status}, not REGISTERED — refusing to print an unfiled receipt.`, @@ -45,6 +52,34 @@ export function toReceiptDocumentModel(receipt: EimsReceipt, invoice: Invoice): // if that default changes for an unrelated reason. sealText: "EDR PAID", extraSummary: [{ label: "Mode of payment", value: req.TransactionDetails.ModeOfPayment }], + mor: config?.invoice + ? { + titleAm: "የገንዘብ መቀበያ ደረሰኝ", + titleEn: "Cash Receipt Voucher", + saleType: config.invoice.transactionType, + systemNumber: req.SourceSystemNumber || config.systemNumber || null, + ...parties(config, invoice), + payment: { + mode: req.TransactionDetails.ModeOfPayment, + typeMethod: config.invoice.paymentTerm, + receiverName: invoice.company?.name ?? null, + }, + receipt: { + rrn: receipt.rrn ?? "", + reason: req.Reason, + collectedAmount: req.CollectedAmount, + // One row per invoice the payment covers — MoR's receipt is invoice-linked, so the + // printed voucher has to show which document(s) the money was applied to. + invoices: req.Invoices.map((line) => ({ + irn: line.InvoiceIRN, + paymentCoverage: line.PaymentCoverage, + totalAmount: line.TotalAmount, + remainingAmount: line.RemainingAmount ?? 0, + paidAmount: line.InvoicePaidAmount, + })), + }, + } + : null, }); } @@ -59,9 +94,63 @@ export function toReceiptDocumentModel(receipt: EimsReceipt, invoice: Invoice): // wrong here, so this is the one case that MUST override it. sealText: "EDR", extraSummary: [{ label: "Withholding type", value: req.WithholdDetail.Type }], + mor: config?.invoice + ? { + titleAm: "ከተከፋይ ሒሳብ ላይ ለተቀነሰ ግብር የተሰጠ ደረሰኝ", + titleEn: "Withholding tax on payment", + ...parties(config, invoice), + systemNumber: req.SourceSystemNumber || config.systemNumber || null, + withholding: { + receiptNumber: receipt.receiptNumber, + counter: req.ReceiptCounter, + reason: req.Reason, + type: req.WithholdDetail.Type, + invoiceCurrency: req.InvoiceDetail.Currency, + preTaxAmount: req.WithholdDetail.PreTaxAmount, + withheldAmount: req.WithholdDetail.WithholdingAmount, + systemType: req.SourceSystemType, + systemNumber: req.SourceSystemNumber || config.systemNumber || "", + }, + } + : null, }); } +/** + * `ከ / From` and `ለ / To` for a receipt. On a withholding receipt the seller is the withholding + * agent and the buyer the taxpayer, which is the same pair of blocks in the same order — the + * layout relabels them, so the mapping does not change. + */ +function parties( + config: EimsConfig, + invoice: Invoice, +): { seller: MorPartyDetails; buyer: MorPartyDetails } { + const seller = buildEimsSeller(config); + const company = invoice.company; + return { + seller: { + name: config.invoice.sellerLegalName || seller.LegalName, + city: seller.City, + subCity: seller.SubCity, + woreda: seller.Wereda, + kebele: seller.Locality, + houseNo: seller.HouseNumber, + tin: seller.Tin, + vatNumber: seller.VatNumber, + }, + buyer: { + name: company?.name ?? "N/A", + city: company?.zone ?? null, + subCity: company?.zone ?? null, + woreda: company?.woreda ?? null, + kebele: company?.kebele ?? null, + houseNo: company?.houseNo ?? null, + tin: company?.tin ?? null, + vatNumber: company?.vatNumber ?? null, + }, + }; +} + function build( receipt: EimsReceipt, invoice: Invoice, @@ -73,6 +162,7 @@ function build( amount: number; sealText: string; extraSummary: Array<{ label: string; value: string | null }>; + mor?: InvoiceDocumentModel["mor"]; }, ): InvoiceDocumentModel { return { diff --git a/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts index 55a38480b..99af6ef30 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts @@ -191,6 +191,7 @@ describe("EimsReceiptService.registerSalesReceipt", () => { it("marks the receipt FAILED on a deterministic rejection and rethrows", async () => { const db = new FakeDb([invoiceRow()]); + const invoiceBefore = { ...db.invoices.get(INVOICE_ID)! }; const postBearer = jest .fn() .mockRejectedValue(new EimsApiException("RULE_VALIDATION", "EIMS receipt failed (406)", 406)); @@ -200,6 +201,7 @@ describe("EimsReceiptService.registerSalesReceipt", () => { ).rejects.toBeInstanceOf(EimsApiException); const [receipt] = [...db.receipts.values()]; expect(receipt.status).toBe(EimsReceiptStatus.Failed); + expect(db.invoices.get(INVOICE_ID)).toEqual(invoiceBefore); }); it("marks the receipt UNKNOWN on an ambiguous failure (never auto-retried)", async () => { diff --git a/apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts b/apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts index f2de2937c..8c98d0293 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts @@ -196,7 +196,7 @@ export class EimsReceiptService { let model: ReturnType; try { - model = toReceiptDocumentModel(receipt, invoice); + model = toReceiptDocumentModel(receipt, invoice, this.cfg); } catch (err) { // Only the mapper's own refusals (not-yet-registered, missing request body) become a 400 — // a genuine PDF-render failure below is left to surface as whatever InvoiceDocumentService 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..ca5cb6a30 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 @@ -116,9 +116,14 @@ export class EimsSellerCacheService implements OnModuleInit { region: data.region, zone: data.zone, woreda: data.woreda, + kebele: data.kebele, }); 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/empty-return-requests/dto/empty-return-request.dto.ts b/apps/edr-freight-api/src/modules/empty-return-requests/dto/empty-return-request.dto.ts new file mode 100644 index 000000000..c588a5b03 --- /dev/null +++ b/apps/edr-freight-api/src/modules/empty-return-requests/dto/empty-return-request.dto.ts @@ -0,0 +1,87 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayNotEmpty, + ArrayUnique, + IsArray, + IsDateString, + IsNumber, + IsOptional, + IsPositive, + IsString, + IsUUID, + MaxLength, + MinLength, +} from 'class-validator'; + +export class CreateEmptyReturnRequestDto { + @ApiProperty({ description: 'Booking the empties came in on.' }) + @IsUUID() + bookingId!: string; + + @ApiProperty({ + type: [String], + description: + 'One container number per empty being returned — the customer types as many as they said they are sending back.', + example: ['TEMU1234567', 'MSCU7654321'], + }) + @IsArray() + @ArrayNotEmpty() + @ArrayUnique() + @IsString({ each: true }) + @MinLength(4, { each: true }) + @MaxLength(64, { each: true }) + containerNumbers!: string[]; +} + +export class ApproveEmptyReturnRequestDto { + @ApiPropertyOptional({ + description: + 'Per-container price to bill. Defaults to the route WITH_RETURN rate the quote was built from.', + }) + @IsOptional() + @IsNumber() + @IsPositive() + unitAmount?: number; + + @ApiPropertyOptional({ + description: 'Currency of `unitAmount`. Defaults to the quote currency (ETB).', + }) + @IsOptional() + @IsString() + @MaxLength(8) + currency?: string; +} + +export class RejectEmptyReturnRequestDto { + @ApiProperty({ description: 'Why the request was turned down — shown to the customer.' }) + @IsString() + @MinLength(3) + reason!: string; +} + +export class ScheduleEmptyReturnRequestDto { + @ApiProperty({ + description: 'The day the customer will hand the empties over.', + example: '2026-09-20', + }) + @IsDateString() + returnDate!: string; + + @ApiProperty({ description: 'Plate of the truck bringing the empties back.' }) + @IsString() + @MinLength(2) + @MaxLength(32) + truckPlateNumber!: string; + + @ApiProperty({ description: 'Driver bringing the empties back.' }) + @IsString() + @MinLength(2) + @MaxLength(120) + truckDriverName!: string; + + @ApiPropertyOptional({ description: 'Truck type (flatbed, container chassis…).' }) + @IsOptional() + @IsString() + @MaxLength(60) + truckType?: string; +} diff --git a/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.controller.ts b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.controller.ts new file mode 100644 index 000000000..06687253b --- /dev/null +++ b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.controller.ts @@ -0,0 +1,137 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { BookingStaff, MixedAudience, PortalCustomer } from '../../common/booking-guards'; +import { hasFreightPermission } from '../../common/freight-permission.util'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { + ApproveEmptyReturnRequestDto, + CreateEmptyReturnRequestDto, + RejectEmptyReturnRequestDto, + ScheduleEmptyReturnRequestDto, +} from './dto/empty-return-request.dto'; +import { EmptyReturnRequestsService } from './empty-return-requests.service'; +import type { EmptyReturnRequestStatus } from './entities/empty-return-request.entity'; + +/** + * Reading the queue is OR'd with the warehouse-inventory key the rest of the + * Imports menu uses, so the staff who already run container returns can open + * it while the dedicated key is still being handed out. Approving and + * rejecting stay on the review key alone — that one is a commercial decision. + */ +const CAN_VIEW = [ + FREIGHT_PERMS.emptyReturnRequests.view, + FREIGHT_PERMS.warehouseInventory.view, +]; + +@ApiTags('empty-return-requests') +@ApiBearerAuth() +@Controller('empty-return-requests') +export class EmptyReturnRequestsController { + constructor(private readonly service: EmptyReturnRequestsService) {} + + @Get() + @BookingStaff(CAN_VIEW) + @ApiOperation({ summary: 'Empty container return requests queue' }) + findAll(@Query('status') status?: string, @Query('bookingId') bookingId?: string) { + return this.service.findAll({ + status: status as EmptyReturnRequestStatus | undefined, + bookingId, + }); + } + + @Get('planned') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) + @ApiOperation({ + summary: 'Scheduled empty returns the warehouse is expecting, with date and truck', + }) + planned() { + return this.service.plannedReturns(); + } + + @Get('eligibility/:bookingId') + @MixedAudience(CAN_VIEW) + @ApiOperation({ + summary: + 'Whether a booking may request an empty return, its free containers, and the price per container', + }) + eligibility( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.eligibility(bookingId, this.portalUserId(user)); + } + + @Get('by-booking/:bookingId') + @MixedAudience(CAN_VIEW) + @ApiOperation({ summary: "A booking's empty return requests, newest first" }) + findForBooking(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + return this.service.findForBooking(bookingId); + } + + @Get(':id') + @MixedAudience(CAN_VIEW) + @ApiOperation({ summary: 'Get an empty return request by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) { + return this.service.findById(id, this.portalUserId(user)); + } + + @Post() + @PortalCustomer() + @ApiOperation({ + summary: 'Customer requests to return empty containers on a booking sold without return', + }) + create(@Body() dto: CreateEmptyReturnRequestDto, @CurrentUser() user: TCurrentUser) { + return this.service.create(dto, user?.id ?? null); + } + + @Post(':id/schedule') + @PortalCustomer() + @ApiOperation({ + summary: 'Customer sets the return date and the truck bringing the empties back', + }) + schedule( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ScheduleEmptyReturnRequestDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.schedule(id, user?.id ?? null, dto); + } + + @Post(':id/approve') + @BookingStaff(FREIGHT_PERMS.emptyReturnRequests.review) + @ApiOperation({ + summary: + 'Approve and bill the request — the price defaults to the route WITH_RETURN rate per container', + }) + approve( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ApproveEmptyReturnRequestDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.approve(id, user?.id ?? null, dto); + } + + @Post(':id/reject') + @BookingStaff(FREIGHT_PERMS.emptyReturnRequests.review) + @ApiOperation({ summary: 'Reject the request with a reason shown to the customer' }) + reject( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RejectEmptyReturnRequestDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.reject(id, user?.id ?? null, dto); + } + + /** + * Staff read any booking's request; a customer is held to their own. Passing + * the user id is what turns the ownership check on, so staff pass null. + */ + private portalUserId(user: TCurrentUser): string | null { + if (hasFreightPermission(user, FREIGHT_PERMS.emptyReturnRequests.review)) return null; + return user?.id ?? null; + } +} diff --git a/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.module.ts b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.module.ts new file mode 100644 index 000000000..f682f80e5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.module.ts @@ -0,0 +1,27 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { registerExchangeModule } from '../exchange-settings/exchange-module-options'; +import { BillingModule } from '../billing/billing.module'; +import { BookingsModule } from '../bookings/bookings.module'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; +import { RuleEngineModule } from '../rule-engine/rule-engine.module'; +import { EmptyReturnRequest } from './entities/empty-return-request.entity'; +import { EmptyReturnRequestsController } from './empty-return-requests.controller'; +import { EmptyReturnRequestsRepository } from './empty-return-requests.repository'; +import { EmptyReturnRequestsService } from './empty-return-requests.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([EmptyReturnRequest]), + BillingModule, + forwardRef(() => BookingsModule), + NotificationInboxModule, + RuleEngineModule, + registerExchangeModule(), + ], + controllers: [EmptyReturnRequestsController], + providers: [EmptyReturnRequestsRepository, EmptyReturnRequestsService], + exports: [EmptyReturnRequestsService], +}) +export class EmptyReturnRequestsModule {} diff --git a/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.repository.ts b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.repository.ts new file mode 100644 index 000000000..067c869ff --- /dev/null +++ b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.repository.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BaseRepository } from '@edr/api-common'; + +import { EmptyReturnRequest } from './entities/empty-return-request.entity'; + +@Injectable() +export class EmptyReturnRequestsRepository extends BaseRepository { + constructor( + @InjectRepository(EmptyReturnRequest) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.spec.ts b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.spec.ts new file mode 100644 index 000000000..5b358b7aa --- /dev/null +++ b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.spec.ts @@ -0,0 +1,413 @@ +import { BadRequestException } from '@nestjs/common'; + +import { EmptyReturnRequestsService } from './empty-return-requests.service'; +import type { EmptyReturnRequest } from './entities/empty-return-request.entity'; + +/** + * The service is mostly gates and pricing over raw SQL, so the SQL is stubbed + * by matching a distinctive fragment of each statement. Every stub returns the + * shape the real query returns. + */ +type QueryStub = Array<[string, unknown]>; + +const booking = { + id: 'b1', + reference: 'BK-2026-000300', + companyId: 'co1', + companyProfileId: 'cp1', + status: 'ARRIVED', + freightType: 'CONTAINER', + equipmentReturn: 'WITHOUT_RETURN', + tradeDirection: 'IMPORT', + originYardId: 'y-dj', + destinationYardId: 'y-mojo', + paymentCurrency: 'ETB', +}; + +function build( + overrides: { + booking?: Partial; + request?: Partial; + rates?: unknown[]; + queries?: QueryStub; + } = {}, +) { + const merged = { ...booking, ...overrides.booking }; + + const requestRow: EmptyReturnRequest = { + id: 'r1', + bookingId: merged.id, + companyId: merged.companyId, + status: 'SUBMITTED', + containerNumbers: ['TEMU1111111', 'TEMU2222222', 'TEMU3333333'], + containerCount: 3, + submittedAt: new Date(), + ...overrides.request, + } as EmptyReturnRequest; + + const stubs: QueryStub = [ + ['FROM freight.booking_container\n', [{ containerTypeId: 'ct-40' }]], + [ + 'upper(bcu.container_number)', + [{ containerNumber: 'TEMU1111111' }, { containerNumber: 'TEMU2222222' }], + ], + ['COALESCE(SUM(quantity), 0)', [{ quantity: '5' }]], + ['unnest(r.container_numbers)', []], + ['COUNT(*) AS outstanding', [{ outstanding: '0' }]], + ...(overrides.queries ?? []), + ]; + + const query = jest.fn(async (sql: string) => { + // Later stubs win, so a test can override one of the defaults. + for (let i = stubs.length - 1; i >= 0; i -= 1) { + if (sql.includes(stubs[i][0])) return stubs[i][1]; + } + return []; + }); + + const requests = { + findById: jest.fn(async () => requestRow), + findAll: jest.fn(async () => [requestRow]), + create: jest.fn(async (data: Partial) => ({ ...requestRow, ...data })), + update: jest.fn(async () => requestRow), + }; + const bookingsService = { + findById: jest.fn(async () => merged), + assertCustomerCanAccessBooking: jest.fn(async () => undefined), + }; + const billing = { generateInvoice: jest.fn(async () => ({ id: 'inv1' })) }; + const notifications = { notify: jest.fn(async () => undefined) }; + const ratesService = { + findLiveRatesDetailed: jest.fn( + async () => + overrides.rates ?? [ + { + trigger: 'WITH_RETURN', + currency: 'USD', + tradeDirection: 'IMPORT', + originYardId: 'y-dj', + destinationYardId: 'y-mojo', + containerTypeId: 'ct-40', + rateValue: '100', + }, + ], + ), + }; + const exchange = { getRate: jest.fn(async () => 120) }; + + const service = new EmptyReturnRequestsService( + requests as never, + { findById: jest.fn(async () => merged) } as never, + bookingsService as never, + billing as never, + notifications as never, + ratesService as never, + exchange as never, + { query } as never, + ); + + return { + service, + requests, + bookingsService, + billing, + notifications, + query, + requestRow, + booking: merged, + }; +} + +describe('EmptyReturnRequestsService — eligibility', () => { + it('lets an arrived container booking sold without return ask for one', async () => { + const { service } = build(); + const result = await service.eligibility('b1', 'user1'); + + expect(result.eligible).toBe(true); + expect(result.reason).toBeNull(); + expect(result.availableContainerNumbers).toEqual(['TEMU1111111', 'TEMU2222222']); + }); + + it('refuses bulk freight — there is no equipment to give back', async () => { + const { service } = build({ booking: { freightType: 'BULK' } }); + const result = await service.eligibility('b1', 'user1'); + + expect(result.eligible).toBe(false); + expect(result.reason).toMatch(/container freight only/i); + }); + + it('refuses a booking that already bought the return service', async () => { + const withReturn = build({ booking: { equipmentReturn: 'WITH_RETURN' } }); + const legacy = build({ booking: { equipmentReturn: 'RETURN' } }); + + expect((await withReturn.service.eligibility('b1', null)).reason).toMatch( + /already ships with/i, + ); + expect((await legacy.service.eligibility('b1', null)).reason).toMatch(/already ships with/i); + }); + + it('refuses a booking that has not shipped yet', async () => { + const { service } = build({ booking: { status: 'PAID' } }); + const result = await service.eligibility('b1', null); + + expect(result.eligible).toBe(false); + expect(result.reason).toMatch(/once the booking is in transit/i); + }); + + it('allows it after delivery, when the empty actually comes back', async () => { + const { service } = build({ booking: { status: 'COMPLETED' } }); + expect((await service.eligibility('b1', null)).eligible).toBe(true); + }); + + it('refuses when every container is already on a request', async () => { + const { service } = build({ + queries: [ + [ + 'unnest(r.container_numbers)', + [{ containerNumber: 'TEMU1111111' }, { containerNumber: 'TEMU2222222' }], + ], + ], + }); + const result = await service.eligibility('b1', null); + + expect(result.eligible).toBe(false); + expect(result.reason).toMatch(/already on an empty return request/i); + }); + + it('refuses a booking with no container numbers to pick from', async () => { + const { service } = build({ queries: [['upper(bcu.container_number)', []]] }); + const result = await service.eligibility('b1', null); + + expect(result.eligible).toBe(false); + expect(result.reason).toMatch(/no container numbers are recorded/i); + expect(result.availableContainerNumbers).toEqual([]); + }); + + it('checks booking ownership for a portal caller, and skips it for staff', async () => { + const portal = build(); + await portal.service.eligibility('b1', 'user1'); + expect(portal.bookingsService.assertCustomerCanAccessBooking).toHaveBeenCalled(); + + const staff = build(); + await staff.service.eligibility('b1', null); + expect(staff.bookingsService.assertCustomerCanAccessBooking).not.toHaveBeenCalled(); + }); +}); + +describe('EmptyReturnRequestsService — creating a request', () => { + it('accepts containers that came in on the booking', async () => { + const { service, requests } = build(); + await service.create( + { bookingId: 'b1', containerNumbers: ['temu1111111', 'TEMU2222222'] }, + 'user1', + ); + + expect(requests.create).toHaveBeenCalledWith( + expect.objectContaining({ + bookingId: 'b1', + containerNumbers: ['TEMU1111111', 'TEMU2222222'], + containerCount: 2, + status: 'SUBMITTED', + }), + ); + }); + + it('refuses a container that is not on the booking', async () => { + const { service, requests } = build(); + + await expect( + service.create( + { bookingId: 'b1', containerNumbers: ['TEMU1111111', 'MSCU9999999'] }, + 'user1', + ), + ).rejects.toThrow(/Not on booking BK-2026-000300: MSCU9999999/); + expect(requests.create).not.toHaveBeenCalled(); + }); + + it('refuses the same container twice', async () => { + const { service } = build(); + + await expect( + service.create( + { bookingId: 'b1', containerNumbers: ['TEMU1111111', 'TEMU1111111'] }, + 'user1', + ), + ).rejects.toThrow(/selected twice/i); + }); + + it('refuses a container already sitting on a live request', async () => { + const { service } = build({ + queries: [['unnest(r.container_numbers)', [{ containerNumber: 'TEMU1111111' }]]], + }); + + await expect( + service.create({ bookingId: 'b1', containerNumbers: ['TEMU1111111'] }, 'user1'), + ).rejects.toThrow(/Already on an empty return request/); + }); + + it('refuses a booking that already ships with return', async () => { + const { service } = build({ booking: { equipmentReturn: 'WITH_RETURN' } }); + + await expect( + service.create({ bookingId: 'b1', containerNumbers: ['TEMU1111111'] }, 'user1'), + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); + +describe('EmptyReturnRequestsService — pricing', () => { + it('prices a container at the route WITH_RETURN rate, converted to birr', async () => { + const { service, booking: b } = build(); + const quote = await service.quote(b as never); + + // 100 USD × 120 ETB/USD + expect(quote).toMatchObject({ unitAmount: 12000, currency: 'ETB', sourceRateUsd: 100 }); + expect(quote.unavailableReason).toBeNull(); + }); + + it('falls back to the route rate that names no container type', async () => { + const { service, booking: b } = build({ + rates: [ + { + trigger: 'WITH_RETURN', + currency: 'USD', + tradeDirection: 'IMPORT', + originYardId: 'y-dj', + destinationYardId: 'y-mojo', + containerTypeId: null, + rateValue: '80', + }, + ], + }); + + expect((await service.quote(b as never)).unitAmount).toBe(9600); + }); + + it('reports no price when no rate covers the route', async () => { + const { service, booking: b } = build({ + rates: [ + { + trigger: 'WITH_RETURN', + currency: 'USD', + tradeDirection: 'EXPORT', + originYardId: 'other', + destinationYardId: 'other', + containerTypeId: null, + rateValue: '80', + }, + ], + }); + const quote = await service.quote(b as never); + + expect(quote.unitAmount).toBeNull(); + expect(quote.unavailableReason).toMatch(/no empty-return rate/i); + }); +}); + +describe('EmptyReturnRequestsService — approval', () => { + it('bills container count × the route rate and stores the invoice', async () => { + const { service, billing, requests } = build(); + await service.approve('r1', 'staff1', {}); + + expect(billing.generateInvoice).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'empty_return_request', + sourceId: 'r1', + currency: 'ETB', + totalAmount: 36000, // 3 × 12,000 + }), + ); + expect(requests.update).toHaveBeenCalledWith( + 'r1', + expect.objectContaining({ + status: 'APPROVED', + quotedUnitAmount: 12000, + quotedTotalAmount: 36000, + invoiceId: 'inv1', + }), + ); + }); + + it("bills the reviewer's override instead of the route rate", async () => { + const { service, billing } = build(); + await service.approve('r1', 'staff1', { unitAmount: 5000 }); + + expect(billing.generateInvoice).toHaveBeenCalledWith( + expect.objectContaining({ totalAmount: 15000 }), + ); + }); + + it('refuses to approve without a price when no rate covers the route', async () => { + const { service } = build({ rates: [] }); + await expect(service.approve('r1', 'staff1', {})).rejects.toBeInstanceOf(BadRequestException); + }); + + it('only approves a submitted request', async () => { + const { service } = build({ request: { status: 'APPROVED' } }); + await expect(service.approve('r1', 'staff1', {})).rejects.toThrow(/Only a submitted request/); + }); +}); + +describe('EmptyReturnRequestsService — scheduling', () => { + const details = { + returnDate: '2026-09-20', + truckPlateNumber: '3-a12345', + truckDriverName: 'Abebe K.', + }; + + it('takes the date and truck once the invoice is paid', async () => { + const { service, requests } = build({ request: { status: 'PAID' } }); + await service.schedule('r1', 'user1', details); + + expect(requests.update).toHaveBeenCalledWith( + 'r1', + expect.objectContaining({ + status: 'SCHEDULED', + requestedReturnDate: '2026-09-20', + truckPlateNumber: '3-A12345', + }), + ); + }); + + it('tells an unpaid customer to pay first', async () => { + const { service } = build({ request: { status: 'APPROVED' } }); + await expect(service.schedule('r1', 'user1', details)).rejects.toThrow( + /Pay the empty return invoice/, + ); + }); +}); + +describe('EmptyReturnRequestsService — payment and completion', () => { + it('moves an approved request to PAID when its invoice settles', async () => { + const { service, requests } = build({ request: { status: 'APPROVED' } }); + await service.onInvoicePaid({ sourceId: 'r1' }); + + expect(requests.update).toHaveBeenCalledWith('r1', expect.objectContaining({ status: 'PAID' })); + }); + + it('ignores a settlement for a request that is not awaiting payment', async () => { + const { service, requests } = build({ request: { status: 'SCHEDULED' } }); + await service.onInvoicePaid({ sourceId: 'r1' }); + + expect(requests.update).not.toHaveBeenCalled(); + }); + + it('completes a scheduled request once every container is recorded back', async () => { + const { service, requests } = build({ request: { status: 'SCHEDULED' } }); + await service.settleScheduledForBooking('b1'); + + expect(requests.update).toHaveBeenCalledWith( + 'r1', + expect.objectContaining({ status: 'COMPLETED' }), + ); + }); + + it('leaves it scheduled while any container is still outstanding', async () => { + const { service, requests } = build({ + request: { status: 'SCHEDULED' }, + queries: [['COUNT(*) AS outstanding', [{ outstanding: '2' }]]], + }); + await service.settleScheduledForBooking('b1'); + + expect(requests.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.ts b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.ts new file mode 100644 index 000000000..aae59de39 --- /dev/null +++ b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.ts @@ -0,0 +1,617 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { DataSource } from 'typeorm'; + +import { ExchangeService } from '@edr/api-common'; +import { Freight, NotificationAudience, NotificationPriority, NotificationType } from '@edr/types'; + +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { BillingService } from '../billing/billing.service'; +import { BookingsRepository } from '../bookings/bookings.repository'; +import { BookingsService } from '../bookings/bookings.service'; +import { Booking } from '../bookings/entities/booking.entity'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { RatesService } from '../rule-engine/services/rates.service'; +import { + ApproveEmptyReturnRequestDto, + CreateEmptyReturnRequestDto, + RejectEmptyReturnRequestDto, + ScheduleEmptyReturnRequestDto, +} from './dto/empty-return-request.dto'; +import { + EmptyReturnRequest, + type EmptyReturnRequestStatus, +} from './entities/empty-return-request.entity'; +import { EmptyReturnRequestsRepository } from './empty-return-requests.repository'; + +/** The invoice `source` this module owns — also the `${source}.invoice.paid` event prefix. */ +const INVOICE_SOURCE = 'empty_return_request'; + +/** + * Booking statuses that may still ask for an empty return. The empty only goes + * back after the cargo is delivered, so everything from departure onward + * qualifies — cutting it off at ARRIVED would take the option away exactly + * when the customer needs it. + */ +const REQUESTABLE_BOOKING_STATUSES = ['IN_TRANSIT', 'ARRIVED', 'COMPLETED']; + +/** Requests that still hold their container numbers — a rejected one releases them. */ +const OPEN_STATUSES: EmptyReturnRequestStatus[] = [ + 'SUBMITTED', + 'APPROVED', + 'PAID', + 'SCHEDULED', + 'COMPLETED', +]; + +export interface EmptyReturnQuote { + /** Per-container price in `currency`; null when no rate covers this route. */ + unitAmount: number | null; + currency: string; + /** The USD route rate the quote came from, before conversion. */ + sourceRateUsd: number | null; + /** Why there is no price, for the UI to show instead of a number. */ + unavailableReason: string | null; +} + +export interface EmptyReturnEligibility { + eligible: boolean; + /** Why the customer cannot request one, when `eligible` is false. */ + reason: string | null; + /** Containers on the booking that are not already spoken for. */ + availableContainerNumbers: string[]; + maxContainers: number; + quote: EmptyReturnQuote; +} + +@Injectable() +export class EmptyReturnRequestsService { + constructor( + private readonly requests: EmptyReturnRequestsRepository, + private readonly bookingsRepository: BookingsRepository, + private readonly bookingsService: BookingsService, + private readonly billing: BillingService, + private readonly notifications: NotificationInboxService, + private readonly ratesService: RatesService, + private readonly exchange: ExchangeService, + private readonly dataSource: DataSource, + ) {} + + // ── reads ──────────────────────────────────────────────────────────────── + + async findAll(filter: { + status?: EmptyReturnRequestStatus; + bookingId?: string; + }): Promise< + Array + > { + return this.dataSource.query( + `SELECT r.*, + b.reference AS "bookingReference", + c.name AS "companyName" + FROM freight.empty_return_requests r + LEFT JOIN freight.bookings b ON b.id = r.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies c ON c.id = r.company_id + WHERE r.deleted_at IS NULL + AND ($1::text IS NULL OR r.status = $1) + AND ($2::uuid IS NULL OR r.booking_id = $2) + ORDER BY r.submitted_at DESC`, + [filter.status ?? null, filter.bookingId ?? null], + ); + } + + /** One request. A portal caller must own the booking; staff pass `null`. */ + async findById(id: string, userId: string | null = null): Promise { + const request = await this.requests.findById(id); + if (!request) throw new NotFoundException(`Empty return request ${id} not found`); + if (userId) { + const booking = await this.bookingsService.findById(request.bookingId); + await this.bookingsService.assertCustomerCanAccessBooking(userId, booking); + } + return request; + } + + /** A booking's own requests — the portal card's history. */ + findForBooking(bookingId: string): Promise { + return this.requests.findAll({ + where: { bookingId }, + order: { submittedAt: 'DESC' }, + }); + } + + /** + * Can this booking ask for an empty return, how many containers are left to + * ask for, and what one would cost. Drives the portal card: the customer + * sees the price before committing, and staff see the same number prefilled + * at approval. + */ + async eligibility(bookingId: string, userId: string | null): Promise { + const booking = await this.bookingsService.findById(bookingId); + if (userId) await this.bookingsService.assertCustomerCanAccessBooking(userId, booking); + + const quote = await this.quote(booking); + const spoken = await this.spokenForContainers(bookingId); + const all = await this.bookingContainerNumbers(bookingId); + const available = all.filter((number) => !spoken.has(number)); + + const reason = this.ineligibilityReason(booking, all.length, available.length); + return { + eligible: reason === null, + reason, + availableContainerNumbers: available, + maxContainers: available.length, + quote, + }; + } + + private ineligibilityReason( + booking: Booking, + bookingContainerCount: number, + availableCount: number, + ): string | null { + if (booking.freightType !== 'CONTAINER') { + return 'Empty container return applies to container freight only.'; + } + if (booking.equipmentReturn === 'WITH_RETURN' || booking.equipmentReturn === 'RETURN') { + return 'This booking already ships with empty container return included.'; + } + if (!REQUESTABLE_BOOKING_STATUSES.includes(booking.status)) { + return `An empty return can be requested once the booking is in transit (current status: ${booking.status}).`; + } + // The customer picks from this booking's own containers, so a booking that + // never captured its container numbers has nothing to pick. + if (bookingContainerCount === 0) { + return 'No container numbers are recorded on this booking — contact EDR to arrange the return.'; + } + if (availableCount === 0) { + return 'Every container on this booking is already on an empty return request.'; + } + return null; + } + + // ── pricing ────────────────────────────────────────────────────────────── + + /** + * Per-container price for returning an empty on this booking, taken from the + * same live WITH_RETURN rate the rule engine bills when the service is + * bought up front (route + trade direction + container type, priced in USD). + * Billed in ETB, converted at the current rate, because this is collected + * locally rather than on the freight invoice. + * + * ponytail: prices off the booking's FIRST container line. A booking mixing + * 20ft and 40ft therefore quotes one size's rate for every box — split the + * quote per container if mixed-size bookings start returning empties. + */ + async quote(booking: Booking): Promise { + const currency = 'ETB'; + if (booking.freightType !== 'CONTAINER') { + return { + unitAmount: null, + currency, + sourceRateUsd: null, + unavailableReason: 'Not container freight.', + }; + } + + const [line]: Array<{ containerTypeId: string | null }> = await this.dataSource.query( + `SELECT container_type_id AS "containerTypeId" + FROM freight.booking_container + WHERE booking_id = $1 AND deleted_at IS NULL + ORDER BY created_at ASC + LIMIT 1`, + [booking.id], + ); + + const rates = await this.ratesService.findLiveRatesDetailed(); + const onLeg = rates.filter( + (rate) => + rate.trigger === 'WITH_RETURN' && + rate.currency === 'USD' && + rate.tradeDirection === booking.tradeDirection && + rate.originYardId === booking.originYardId && + rate.destinationYardId === booking.destinationYardId, + ); + const rate = + onLeg.find((r) => r.containerTypeId === (line?.containerTypeId ?? null)) ?? + onLeg.find((r) => !r.containerTypeId); + + if (!rate) { + return { + unitAmount: null, + currency, + sourceRateUsd: null, + unavailableReason: + 'No empty-return rate covers this route and container type — enter the amount manually.', + }; + } + + const usdToEtb = await this.exchange.getRate('USD', 'ETB'); + const rateUsd = Number(rate.rateValue); + return { + unitAmount: Math.round(rateUsd * usdToEtb * 100) / 100, + currency, + sourceRateUsd: rateUsd, + unavailableReason: null, + }; + } + + // ── customer actions ───────────────────────────────────────────────────── + + async create( + dto: CreateEmptyReturnRequestDto, + userId: string | null, + ): Promise { + const booking = await this.bookingsService.findById(dto.bookingId); + if (userId) await this.bookingsService.assertCustomerCanAccessBooking(userId, booking); + + const numbers = dto.containerNumbers.map((n) => n.trim().toUpperCase()).filter(Boolean); + if (numbers.length === 0) { + throw new BadRequestException('Select at least one container.'); + } + if (new Set(numbers).size !== numbers.length) { + throw new BadRequestException('The same container is selected twice.'); + } + + // Only this booking's own containers can be returned against it. The + // portal offers a pick list, so anything else is a stale page or a + // hand-made request. + const onBooking = new Set(await this.bookingContainerNumbers(booking.id)); + const foreign = numbers.filter((number) => !onBooking.has(number)); + if (foreign.length > 0) { + throw new BadRequestException( + `Not on booking ${booking.reference ?? booking.id}: ${foreign.join(', ')}`, + ); + } + + const reason = this.ineligibilityReason(booking, onBooking.size, numbers.length); + if (reason) throw new BadRequestException(reason); + + await this.assertContainersFree(numbers); + + const saved = await this.requests.create({ + bookingId: booking.id, + companyId: booking.companyId ?? null, + status: 'SUBMITTED', + containerNumbers: numbers, + containerCount: numbers.length, + submittedByUserId: userId, + submittedAt: new Date(), + } as Partial); + + void this.notifications.notify({ + recipients: { permissionKeys: [FREIGHT_PERMS.emptyReturnRequests.review] }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.BOOKING_STATUS, + title: 'Empty container return requested', + body: `${booking.reference ?? booking.id}: a customer asked to return ${numbers.length} empty container${ + numbers.length === 1 ? '' : 's' + }.`, + link: '/dashboard/empty-return-requests', + data: { bookingId: booking.id, requestId: saved.id }, + priority: NotificationPriority.HIGH, + }); + + return saved; + } + + /** Date + truck, once the invoice is settled. This is what the warehouse then expects. */ + async schedule( + id: string, + userId: string | null, + dto: ScheduleEmptyReturnRequestDto, + ): Promise { + const request = await this.findById(id, userId); + if (request.status !== 'PAID' && request.status !== 'SCHEDULED') { + throw new BadRequestException( + request.status === 'APPROVED' + ? 'Pay the empty return invoice before booking a date.' + : `This request cannot be scheduled (current status: ${request.status}).`, + ); + } + + await this.requests.update(id, { + status: 'SCHEDULED', + requestedReturnDate: dto.returnDate, + truckPlateNumber: dto.truckPlateNumber.trim().toUpperCase(), + truckDriverName: dto.truckDriverName.trim(), + truckType: dto.truckType?.trim() ?? null, + scheduledAt: new Date(), + } as Partial); + + void this.notifications.notify({ + recipients: { permissionKeys: [FREIGHT_PERMS.emptyReturnRequests.review] }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.BOOKING_STATUS, + title: 'Empty return scheduled', + body: `${request.containerCount} empty container${request.containerCount === 1 ? '' : 's'} arriving ${ + dto.returnDate + } on truck ${dto.truckPlateNumber}.`, + link: '/dashboard/container-returns', + data: { bookingId: request.bookingId, requestId: id }, + }); + + return this.findById(id); + } + + // ── staff actions ──────────────────────────────────────────────────────── + + /** + * Approve and bill. The reviewer's `unitAmount` wins; otherwise the route + * rate stands. The invoice is issued here, so the customer can pay straight + * away — payment lands back on `onInvoicePaid`. + */ + async approve( + id: string, + staffId: string | null, + dto: ApproveEmptyReturnRequestDto, + ): Promise { + const request = await this.findById(id); + if (request.status !== 'SUBMITTED') { + throw new BadRequestException( + `Only a submitted request can be approved (current status: ${request.status}).`, + ); + } + + const booking = await this.bookingsService.findById(request.bookingId); + // `chk_invoices_single_payer` requires exactly one payer, and this invoice + // is always billed to the customer — so a booking with no company cannot + // be invoiced at all. Say so here rather than at the constraint. + if (!booking.companyId) { + throw new BadRequestException( + `Booking ${booking.reference ?? booking.id} has no company to bill — the empty return cannot be invoiced.`, + ); + } + + const quote = await this.quote(booking); + const unitAmount = dto.unitAmount ?? quote.unitAmount; + if (!unitAmount || unitAmount <= 0) { + throw new BadRequestException( + quote.unavailableReason ?? 'No price for this return — enter the per-container amount.', + ); + } + + const currency = dto.currency ?? quote.currency; + const totalAmount = Math.round(unitAmount * request.containerCount * 100) / 100; + + const invoice = await this.billing.generateInvoice({ + source: INVOICE_SOURCE as Freight.InvoiceSource, + sourceId: request.id, + type: 'EMPTY_RETURN', + companyId: booking.companyId, + companyProfileId: booking.companyProfileId || '', + currency, + lines: [ + { + chargeType: 'CONTAINER_WITH_RETURN', + description: `Empty container return — ${request.containerCount} container${ + request.containerCount === 1 ? '' : 's' + } on booking ${booking.reference ?? booking.id}`, + amount: totalAmount, + }, + ], + totalAmount, + }); + + await this.requests.update(id, { + status: 'APPROVED', + quotedUnitAmount: unitAmount, + quotedTotalAmount: totalAmount, + currency, + invoiceId: invoice.id, + reviewedByStaffId: staffId, + reviewedAt: new Date(), + } as Partial); + + if (booking.companyId) { + void this.notifications.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.INVOICE_ISSUED, + title: 'Empty container return approved — payment due', + body: `Your empty return request for booking ${booking.reference ?? booking.id} was approved: ${totalAmount.toLocaleString()} ${currency} for ${request.containerCount} container${ + request.containerCount === 1 ? '' : 's' + }. Pay the invoice, then choose your return date and truck.`, + link: `/bookings/${booking.id}`, + data: { bookingId: booking.id, requestId: id, invoiceId: invoice.id }, + priority: NotificationPriority.HIGH, + }); + } + + return this.findById(id); + } + + async reject( + id: string, + staffId: string | null, + dto: RejectEmptyReturnRequestDto, + ): Promise { + const request = await this.findById(id); + if (request.status !== 'SUBMITTED') { + throw new BadRequestException( + `Only a submitted request can be rejected (current status: ${request.status}).`, + ); + } + + await this.requests.update(id, { + status: 'REJECTED', + reviewedByStaffId: staffId, + reviewedAt: new Date(), + rejectionReason: dto.reason, + } as Partial); + + const booking = await this.bookingsRepository.findById(request.bookingId); + if (booking?.companyId) { + void this.notifications.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: 'Empty container return rejected', + body: `Your empty return request for booking ${booking.reference ?? request.bookingId} was rejected: ${dto.reason}`, + link: `/bookings/${request.bookingId}`, + data: { bookingId: request.bookingId, requestId: id }, + priority: NotificationPriority.HIGH, + }); + } + + return this.findById(id); + } + + // ── warehouse handoff ──────────────────────────────────────────────────── + + /** + * Scheduled requests the warehouse is waiting on — the planned side of the + * Container Returns screen. Containers already recorded as returned are + * carried per request so staff confirm only what is still outstanding. + */ + async plannedReturns(): Promise< + Array<{ + requestId: string; + bookingId: string; + bookingReference: string | null; + companyName: string | null; + companyId: string | null; + requestedReturnDate: string | null; + truckPlateNumber: string | null; + truckDriverName: string | null; + truckType: string | null; + containers: Array<{ containerNumber: string; returnId: string | null }>; + }> + > { + return this.dataSource.query( + `SELECT r.id AS "requestId", + r.booking_id AS "bookingId", + b.reference AS "bookingReference", + c.name AS "companyName", + r.company_id AS "companyId", + r.requested_return_date AS "requestedReturnDate", + r.truck_plate_number AS "truckPlateNumber", + r.truck_driver_name AS "truckDriverName", + r.truck_type AS "truckType", + ( + SELECT json_agg(json_build_object( + 'containerNumber', n, + 'returnId', ( + SELECT er.id FROM freight.empty_container_returns er + WHERE er.deleted_at IS NULL + AND er.booking_id = r.booking_id + AND upper(er.container_number) = upper(n) + ORDER BY er.created_at DESC LIMIT 1 + ) + ) ORDER BY ord) + FROM unnest(r.container_numbers) WITH ORDINALITY AS t(n, ord) + ) AS containers + FROM freight.empty_return_requests r + LEFT JOIN freight.bookings b ON b.id = r.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies c ON c.id = r.company_id + WHERE r.deleted_at IS NULL + AND r.status = 'SCHEDULED' + ORDER BY r.requested_return_date ASC NULLS LAST, r.scheduled_at ASC`, + ); + } + + /** + * Close a scheduled request once every container it covers has been recorded + * as returned. Called after the warehouse records the returns; a request + * with anything still outstanding stays SCHEDULED. + */ + async settleScheduledForBooking(bookingId: string): Promise { + const open = await this.requests.findAll({ + where: { bookingId, status: 'SCHEDULED' }, + }); + + for (const request of open) { + const [{ outstanding }]: Array<{ outstanding: string }> = await this.dataSource.query( + `SELECT COUNT(*) AS outstanding + FROM unnest($2::text[]) AS n + WHERE NOT EXISTS ( + SELECT 1 FROM freight.empty_container_returns er + WHERE er.deleted_at IS NULL + AND er.booking_id = $1 + AND upper(er.container_number) = upper(n) + )`, + [bookingId, request.containerNumbers], + ); + if (Number(outstanding) > 0) continue; + + await this.requests.update(request.id, { + status: 'COMPLETED', + completedAt: new Date(), + } as Partial); + } + } + + // ── payment ────────────────────────────────────────────────────────────── + + /** Gateway and manual settlements both land here (`${source}.invoice.paid`). */ + @OnEvent(`${INVOICE_SOURCE}.invoice.paid`) + async onInvoicePaid(payload: { sourceId: string }): Promise { + const request = await this.requests.findById(payload.sourceId); + if (!request || request.status !== 'APPROVED') return; + + await this.requests.update(request.id, { + status: 'PAID', + paidAt: new Date(), + } as Partial); + + const booking = await this.bookingsRepository.findById(request.bookingId); + if (!booking?.companyId) return; + void this.notifications.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.PAYMENT_RECEIVED, + title: 'Empty return paid — choose your return date', + body: `Payment received for the empty return on booking ${booking.reference ?? request.bookingId}. Tell us the date and the truck bringing the containers back.`, + link: `/bookings/${request.bookingId}`, + data: { bookingId: request.bookingId, requestId: request.id }, + priority: NotificationPriority.HIGH, + }); + } + + // ── helpers ────────────────────────────────────────────────────────────── + + /** Container numbers captured on the booking, upper-cased. */ + private async bookingContainerNumbers(bookingId: string): Promise { + const rows: Array<{ containerNumber: string }> = await this.dataSource.query( + `SELECT DISTINCT upper(bcu.container_number) AS "containerNumber" + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 + AND bcu.deleted_at IS NULL + AND bcu.container_number IS NOT NULL + ORDER BY 1`, + [bookingId], + ); + return rows.map((row) => row.containerNumber); + } + + /** Numbers already claimed by a live request on this booking. */ + private async spokenForContainers(bookingId: string): Promise> { + const rows: Array<{ containerNumber: string }> = await this.dataSource.query( + `SELECT DISTINCT upper(n) AS "containerNumber" + FROM freight.empty_return_requests r, unnest(r.container_numbers) AS n + WHERE r.deleted_at IS NULL + AND r.booking_id = $1 + AND r.status = ANY($2)`, + [bookingId, OPEN_STATUSES], + ); + return new Set(rows.map((row) => row.containerNumber)); + } + + /** A container may only sit on one live request at a time, on any booking. */ + private async assertContainersFree(numbers: string[]): Promise { + const rows: Array<{ containerNumber: string }> = await this.dataSource.query( + `SELECT DISTINCT upper(n) AS "containerNumber" + FROM freight.empty_return_requests r, unnest(r.container_numbers) AS n + WHERE r.deleted_at IS NULL + AND r.status = ANY($1) + AND upper(n) = ANY($2)`, + [OPEN_STATUSES, numbers], + ); + if (rows.length > 0) { + throw new BadRequestException( + `Already on an empty return request: ${rows.map((r) => r.containerNumber).join(', ')}`, + ); + } + } +} diff --git a/apps/edr-freight-api/src/modules/empty-return-requests/entities/empty-return-request.entity.ts b/apps/edr-freight-api/src/modules/empty-return-requests/entities/empty-return-request.entity.ts new file mode 100644 index 000000000..6bb8257be --- /dev/null +++ b/apps/edr-freight-api/src/modules/empty-return-requests/entities/empty-return-request.entity.ts @@ -0,0 +1,127 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; + +export const EMPTY_RETURN_REQUEST_STATUSES = [ + /** Customer named the containers; waiting on operations. */ + 'SUBMITTED', + /** Operations approved and priced it; the invoice is out, waiting on payment. */ + 'APPROVED', + 'REJECTED', + /** Invoice settled; waiting on the customer to book a date and a truck. */ + 'PAID', + /** Date and truck given — the warehouse now expects these empties. */ + 'SCHEDULED', + /** The empties arrived and were recorded as returns. */ + 'COMPLETED', + 'CANCELLED', +] as const; + +export type EmptyReturnRequestStatus = (typeof EMPTY_RETURN_REQUEST_STATUSES)[number]; + +/** + * A customer's request to return empties on a booking that did NOT buy the + * return service up front (`equipment_return` is not WITH_RETURN). Container + * freight only — a bulk booking has no equipment to give back. + * + * The request carries the commercial half of the flow: which containers, what + * operations priced it at, the invoice, and the date/truck the customer + * booked. The physical return is still recorded in `empty_container_returns` + * when the truck arrives, which is what closes this row out as COMPLETED. + */ +@Entity({ schema: 'freight', name: 'empty_return_requests' }) +@Index(['bookingId']) +@Index(['status']) +export class EmptyReturnRequest extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + /** Denormalised at submit so the queue and the invoice agree on the payer. */ + @Column({ name: 'company_id', type: 'uuid', nullable: true }) + companyId?: string | null; + + @Column({ name: 'status', type: 'varchar', length: 30, default: 'SUBMITTED' }) + status!: EmptyReturnRequestStatus; + + /** The container numbers the customer is sending back, as typed. */ + @Column({ name: 'container_numbers', type: 'text', array: true, default: () => "'{}'" }) + containerNumbers!: string[]; + + @Column({ name: 'container_count', type: 'smallint', default: 0 }) + containerCount!: number; + + /** Per-container price at approval — the route's WITH_RETURN rate, or the reviewer's override. */ + @Column({ + name: 'quoted_unit_amount', + type: 'numeric', + precision: 14, + scale: 2, + nullable: true, + transformer: { + to: (v?: number | null) => v, + from: (v?: string | null) => (v == null ? null : Number(v)), + }, + }) + quotedUnitAmount?: number | null; + + @Column({ + name: 'quoted_total_amount', + type: 'numeric', + precision: 14, + scale: 2, + nullable: true, + transformer: { + to: (v?: number | null) => v, + from: (v?: string | null) => (v == null ? null : Number(v)), + }, + }) + quotedTotalAmount?: number | null; + + @Column({ name: 'currency', type: 'varchar', length: 8, nullable: true }) + currency?: string | null; + + @Column({ name: 'invoice_id', type: 'uuid', nullable: true }) + invoiceId?: string | null; + + @Column({ name: 'paid_at', type: 'timestamptz', nullable: true }) + paidAt?: Date | null; + + /** Customer's chosen day for handing the empties over. */ + @Column({ name: 'requested_return_date', type: 'date', nullable: true }) + requestedReturnDate?: string | null; + + @Column({ name: 'truck_plate_number', type: 'varchar', length: 32, nullable: true }) + truckPlateNumber?: string | null; + + @Column({ name: 'truck_driver_name', type: 'varchar', length: 120, nullable: true }) + truckDriverName?: string | null; + + @Column({ name: 'truck_type', type: 'varchar', length: 60, nullable: true }) + truckType?: string | null; + + @Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true }) + scheduledAt?: Date | null; + + @Column({ name: 'submitted_by_user_id', type: 'uuid', nullable: true }) + submittedByUserId?: string | null; + + @Column({ name: 'submitted_at', type: 'timestamptz', default: () => 'now()' }) + submittedAt!: Date; + + @Column({ name: 'reviewed_by_staff_id', type: 'uuid', nullable: true }) + reviewedByStaffId?: string | null; + + @Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true }) + reviewedAt?: Date | null; + + @Column({ name: 'rejection_reason', type: 'text', nullable: true }) + rejectionReason?: string | null; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt?: Date | null; +} diff --git a/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts index c9f2a7d21..69ff22927 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts @@ -1,4 +1,17 @@ +import { DataSource } from 'typeorm'; + import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; +import { + CARGO_TYPE_SUBTREE_SQL, + bookingContainerCountSql, + bookingContentMatchSql, + bookingContainerVgmSql, + bookingContentSql, + bookingHasContainerTypeSql, + bookingRequestedCargoSql, + bookingRequestedContainerCountSql, +} from '../../bookings/booking-content.sql'; +import { bookingTonsSql } from '../../bookings/booking-tons.sql'; import { Booking } from '../../bookings/entities/booking.entity'; import { Company } from '../../companies/entities/company.entity'; import { CompanyProfile } from '../../companies/entities/company-profile.entity'; @@ -10,23 +23,104 @@ import { Yard } from '../../rule-engine/entities/yard.entity'; import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity'; import { Train } from '../../trains/entities/train.entity'; import { applyDirectionScope } from '../../user-trade-access/trade-scope.util'; -import { ExportDataset } from '../export.types'; +import { ExportFilterOption } from '../export-filter.util'; +import { ExportDataset, ExportField } from '../export.types'; /** * Domain semantics that the retired `bookings-list` report used to share. - * Kept identical on purpose — for PER_ITEM bulk bookings `cargo_total_weight_vgm` - * holds an item COUNT, not tonnage, and `adjusted_total_amount` silently - * overrides `total_amount`. Getting either wrong misreports money or weight. + * Tonnage is `bookingTonsSql` — the one resolver for the three ways a booking + * stores its weight. `adjusted_total_amount` silently overrides `total_amount`. + * Getting either wrong misreports money or weight. */ -const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; +const TONS = bookingTonsSql('b'); const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)'; +/** What the customer described as the booking's contents — see the helper. */ +const CONTENT = bookingContentSql('b'); +const CONTAINER_COUNT = bookingContainerCountSql('b'); +const REQUESTED_COUNT = bookingRequestedContainerCountSql('b'); + const STATUS_OPTIONS = [ 'DRAFT', 'SUBMITTED', 'UNDER_REVIEW', 'APPROVED', 'REJECTED', 'CANCELLED', 'EXPIRED', 'SCHEDULED', 'LOADED', 'IN_TRANSIT', 'ARRIVED', 'DELIVERED', 'COMPLETED', ].map((v) => ({ value: v, label: v.replace(/_/g, ' ') })); +/** + * Cargo tree flattened for a single select: groups and every commodity beneath + * them, each labelled by its full path ("Bulk → Wheat") the way the booking + * wizard shows a deep leaf. Picking a group row filters its whole subtree. + * + * Recursive because `cargo_types` is arbitrary-depth, not two levels. + */ +async function cargoTypeOptions(ds: DataSource): Promise { + return ds.query(` + WITH RECURSIVE t AS ( + SELECT id, display_order, 0 AS depth, + ARRAY[display_order]::int[] AS ord, + ARRAY[cargo_type_name]::text[] AS path + FROM freight.cargo_types + WHERE parent_group_id IS NULL AND deleted_at IS NULL AND is_active + UNION ALL + SELECT c.id, c.display_order, t.depth + 1, + t.ord || c.display_order, + t.path || c.cargo_type_name + FROM freight.cargo_types c + JOIN t ON c.parent_group_id = t.id + WHERE c.deleted_at IS NULL AND c.is_active + ) + SELECT id AS value, array_to_string(path, ' → ') AS label + FROM t ORDER BY ord, path + `) as Promise; +} + +/** Container types are 2 rows that change about never. */ +async function containerTypeOptions(ds: DataSource): Promise { + return ds.query(` + SELECT id AS value, COALESCE(label, code) AS label + FROM freight.container_types + WHERE deleted_at IS NULL AND is_active + ORDER BY display_order, code + `) as Promise; +} + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * One column per container type ("20FT", "40FT", …), each the box count of + * that type on the booking. Resolved from `container_types` rather than + * hardcoded, so adding a 45ft adds its column without a deploy of this file. + * + * The type id is INTERPOLATED, not bound — `ExportField.select` is a raw SQL + * string with no parameter bag — so ids that are not uuids are dropped rather + * than spliced. They come from our own table; the guard is for the day someone + * changes that column's type. + */ +async function containerTypeFields(ds: DataSource): Promise { + const rows: Array<{ id: string; code: string; label: string | null }> = await ds.query(` + SELECT id, code, label + FROM freight.container_types + WHERE deleted_at IS NULL AND is_active + ORDER BY display_order, code + `); + return rows + .filter((r) => UUID_RE.test(r.id)) + .map((r) => { + const name = r.label || r.code; + return { + key: `containers${r.code.replace(/[^A-Za-z0-9]/g, '')}`, + label: `${name} containers`, + type: 'number' as const, + group: 'cargo', + select: `(SELECT COALESCE(SUM(bc.quantity), 0) + FROM freight.booking_container bc + WHERE bc.booking_id = b.id + AND bc.deleted_at IS NULL + AND bc.container_type_id = '${r.id}')::int`, + }; + }); +} + export const bookingsDataset: ExportDataset = { key: 'bookings', title: 'Bookings', @@ -112,10 +206,21 @@ export const bookingsDataset: ExportDataset = { { key: 'serviceType', label: 'Service type', type: 'string', group: 'route', requires: ['st'], select: 'st.service_name' }, // ---- Cargo ----------------------------------------------------------- - { key: 'cargo', label: 'Cargo', type: 'string', group: 'cargo', default: true, requires: ['cty'], select: 'COALESCE(cty.cargo_type_name, b.cargo_free_text)' }, + // What the customer said is in the booking. `cargo` below is the narrower + // commodity-only view, kept for saved presets that already tick it. + { key: 'content', label: 'Content', type: 'string', group: 'cargo', default: true, select: CONTENT, sortExpr: CONTENT }, + { key: 'cargo', label: 'Cargo (commodity)', type: 'string', group: 'cargo', requires: ['cty'], select: 'COALESCE(cty.cargo_type_name, b.cargo_free_text)' }, + { key: 'cargoDescription', label: 'Cargo description', type: 'string', group: 'cargo', select: 'b.cargo_free_text' }, + // Boxes, not lines: booking_container is one row per LINE with a quantity. + { key: 'containerCount', label: 'Containers', type: 'number', group: 'cargo', default: true, select: `${CONTAINER_COUNT}::int`, sortExpr: CONTAINER_COUNT }, + // Declared on the shipment request, not yet on the booking — see the helper. + { key: 'requestedCargo', label: 'Requested cargo', type: 'string', group: 'cargo', select: bookingRequestedCargoSql('b') }, + { key: 'requestedContainers', label: 'Requested containers', type: 'number', group: 'cargo', select: `${REQUESTED_COUNT}::int`, sortExpr: REQUESTED_COUNT }, { key: 'freightType', label: 'Freight type', type: 'string', group: 'cargo', default: true, select: 'b.freight_type' }, { key: 'tons', label: 'Tonnage', type: 'tons', group: 'cargo', default: true, select: `ROUND(${TONS})::float8`, sortExpr: TONS }, - { key: 'containerWeightVgm', label: 'Container VGM', type: 'number', group: 'cargo', select: 'b.cargo_total_weight_vgm' }, + // The per-line sum, NOT b.cargo_total_weight_vgm — the portal leaves that + // column at 0 for container freight, so it read 0 for every such booking. + { key: 'containerWeightVgm', label: 'Container VGM (t)', type: 'tons', group: 'cargo', select: `${bookingContainerVgmSql('b')}::float8`, sortExpr: bookingContainerVgmSql('b') }, { key: 'bulkWeightTons', label: 'Bulk weight (t)', type: 'tons', group: 'cargo', select: 'b.bulk_total_weight_tons' }, { key: 'isHazardous', label: 'Hazardous', type: 'boolean', group: 'cargo', select: 'b.is_hazardous' }, { key: 'isReefer', label: 'Reefer', type: 'boolean', group: 'cargo', select: 'b.is_reefer' }, @@ -172,6 +277,8 @@ export const bookingsDataset: ExportDataset = { { key: 'doubleHandling', label: 'Double handling', type: 'boolean', group: 'clearance', select: 'b.double_handling' }, ], + dynamicFields: containerTypeFields, + filters: [ { key: 'created', label: 'Created', type: 'daterange' }, { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, @@ -190,6 +297,13 @@ export const bookingsDataset: ExportDataset = { { value: 'PAID', label: 'Paid' }, { value: 'FAILED', label: 'Failed' }, ] }, + { key: 'cargoTypeId', label: 'Content (cargo type)', type: 'select', optionsQuery: cargoTypeOptions }, + { key: 'cargoText', label: 'Content contains', type: 'text' }, + { key: 'containerTypeId', label: 'Container type', type: 'select', optionsQuery: containerTypeOptions }, + { key: 'containersMin', label: 'Containers (min)', type: 'text' }, + { key: 'containersMax', label: 'Containers (max)', type: 'text' }, + { key: 'requestedContainersMin', label: 'Requested containers (min)', type: 'text' }, + { key: 'requestedContainersMax', label: 'Requested containers (max)', type: 'text' }, { key: 'companyId', label: 'Customer', type: 'text' }, { key: 'search', label: 'Search reference or customer', type: 'text' }, ], @@ -210,6 +324,23 @@ export const bookingsDataset: ExportDataset = { if (params.tradeDirection) qb.andWhere('b.trade_direction = :tradeDirection', { tradeDirection: params.tradeDirection }); if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType }); + // Group or leaf — a group matches its whole subtree (see CARGO_TYPE_SUBTREE_SQL). + if (params.cargoTypeId) qb.andWhere(`b.cargo_type_id IN ${CARGO_TYPE_SUBTREE_SQL}`, { cargoTypeId: params.cargoTypeId }); + if (params.cargoText) qb.andWhere(bookingContentMatchSql('b'), { cargoText: `%${params.cargoText as string}%` }); + if (params.containerTypeId) qb.andWhere(bookingHasContainerTypeSql('b'), { containerTypeId: params.containerTypeId }); + // With a container type picked the count is of THAT type, else of every box. + const containerCount = bookingContainerCountSql('b', Boolean(params.containerTypeId)); + // coerceFilterParams yields null (not undefined) for an unset filter, and + // Number(null) is 0 — which would silently apply ">= 0" to every export. + const num = (v: unknown) => (v == null || v === '' ? NaN : Number(v)); + const min = num(params.containersMin); + const max = num(params.containersMax); + if (Number.isFinite(min)) qb.andWhere(`${containerCount} >= :containersMin`, { containersMin: min }); + if (Number.isFinite(max)) qb.andWhere(`${containerCount} <= :containersMax`, { containersMax: max }); + const reqMin = num(params.requestedContainersMin); + const reqMax = num(params.requestedContainersMax); + if (Number.isFinite(reqMin)) qb.andWhere(`${REQUESTED_COUNT} >= :requestedContainersMin`, { requestedContainersMin: reqMin }); + if (Number.isFinite(reqMax)) qb.andWhere(`${REQUESTED_COUNT} <= :requestedContainersMax`, { requestedContainersMax: reqMax }); if (params.paymentStatus) qb.andWhere('b.payment_status = :paymentStatus', { paymentStatus: params.paymentStatus }); if (params.companyId) qb.andWhere('b.company_id = :companyId', { companyId: params.companyId }); if (params.search) { diff --git a/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts index a840f54a0..d36bbecd6 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts @@ -122,6 +122,16 @@ export const customersDataset: ExportDataset = { { value: 'ethiopian', label: 'Ethiopian' }, { value: 'foreign', label: 'Foreign' }, ] }, + // The operational role, NOT `type` above — the list's Role pill. One + // `customer` company routinely holds several profiles, so this asks "who + // does X?" rather than "what kind of company is this?". + { key: 'profileType', label: 'Role', type: 'select', options: [ + { value: 'importer', label: 'Importer' }, + { value: 'exporter', label: 'Exporter' }, + { value: 'freight_forwarder', label: 'Freight forwarder' }, + { value: 'dj_freight_forwarder', label: 'DJ freight forwarder' }, + { value: 'transporter', label: 'Transporter' }, + ] }, // The list's Status filter folds the review queues in, and sends these two // alongside `status`. They are predicates, not columns — see // `company-scope.sql.ts`, shared with the list so both agree exactly. @@ -147,6 +157,18 @@ export const customersDataset: ExportDataset = { if (params.kind) qb.andWhere('c.kind = :kind', { kind: params.kind }); if (params.status) qb.andWhere('c.status = :status', { status: params.status }); if (params.nationality) qb.andWhere('c.nationality = :nationality', { nationality: params.nationality }); + if (params.profileType) { + // EXISTS, matching the list repository exactly — a join here would + // multiply a company holding two profiles into two rows and put the file + // out of step with the count endpoint. + qb.andWhere( + `EXISTS (SELECT 1 FROM freight.company_profiles cp_type + WHERE cp_type.company_id = c.id + AND cp_type.deleted_at IS NULL + AND cp_type.type = :profileType)`, + { profileType: params.profileType }, + ); + } if (params.onboardingCompleted) { const draft = companyDraftSql('c'); qb.andWhere(params.onboardingCompleted === 'true' ? `NOT ${draft}` : draft); diff --git a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts index 56ab3b74e..d4d635f6f 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts @@ -123,6 +123,7 @@ export const invoicesDataset: ExportDataset = { // on-screen filter actually carries into the export. { key: 'status', label: 'Status (single)', type: 'text' }, { key: 'sources', label: 'Source', type: 'multiselect' }, + { key: 'types', label: 'Type', type: 'multiselect' }, { key: 'eimsStatuses', label: 'EIMS status', type: 'multiselect' }, { key: 'paymentMethods', label: 'Payment method', type: 'multiselect' }, { key: 'currency', label: 'Currency', type: 'select', options: [ @@ -151,6 +152,8 @@ export const invoicesDataset: ExportDataset = { if (params.status) qb.andWhere('i.status = :status', { status: params.status }); const sources = params.sources as string[] | null; if (sources?.length) qb.andWhere('i.source IN (:...sources)', { sources }); + const types = params.types as string[] | null; + if (types?.length) qb.andWhere('i.type IN (:...types)', { types }); const eimsStatuses = params.eimsStatuses as string[] | null; if (eimsStatuses?.length) qb.andWhere('i.eims_status IN (:...eimsStatuses)', { eimsStatuses }); const paymentMethods = params.paymentMethods as string[] | null; diff --git a/apps/edr-freight-api/src/modules/exports/datasets/train-schedules.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/train-schedules.dataset.ts index bb31591da..d380470de 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/train-schedules.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/train-schedules.dataset.ts @@ -1,4 +1,5 @@ import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; +import { bookingTonsSql } from '../../bookings/booking-tons.sql'; import { Route } from '../../routes/entities/route.entity'; import { Yard } from '../../rule-engine/entities/yard.entity'; import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity'; @@ -86,7 +87,7 @@ export const trainSchedulesDataset: ExportDataset = { }, { key: 'totalWeightTons', label: 'Total weight (t)', type: 'tons', group: 'load', default: true, - select: `(SELECT ROUND(COALESCE(SUM(COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)), 0))::float8 + select: `(SELECT ROUND(COALESCE(SUM(${bookingTonsSql('b')}), 0))::float8 FROM freight.bookings b WHERE b.train_schedule_id = sch.id AND b.deleted_at IS NULL)`, }, diff --git a/apps/edr-freight-api/src/modules/exports/export-filter.util.ts b/apps/edr-freight-api/src/modules/exports/export-filter.util.ts index c302d9d3b..40c8e6980 100644 --- a/apps/edr-freight-api/src/modules/exports/export-filter.util.ts +++ b/apps/edr-freight-api/src/modules/exports/export-filter.util.ts @@ -1,5 +1,7 @@ import { DataSource } from 'typeorm'; +import type { ExportField } from './export.types'; + const DAY_MS = 24 * 60 * 60 * 1000; export type ExportFilterType = 'daterange' | 'date' | 'select' | 'multiselect' | 'text'; @@ -64,6 +66,26 @@ export function coerceFilterParams( */ const optionsCache = new Map(); +/** Process-lifetime cache for `dynamicFields`, keyed by dataset. */ +const fieldsCache = new Map(); + +/** + * A dataset's full field list: its static fields plus whatever `dynamicFields` + * resolves from the DB. Every read of `dataset.fields` goes through this, so + * the catalog and the download agree on which keys exist. + */ +export async function resolveDatasetFields( + dataset: { key: string; fields: ExportField[]; dynamicFields?: (ds: DataSource) => Promise }, + ds: DataSource, +): Promise { + if (!dataset.dynamicFields) return dataset.fields; + const cached = fieldsCache.get(dataset.key); + if (cached) return cached; + const resolved = [...dataset.fields, ...(await dataset.dynamicFields(ds))]; + fieldsCache.set(dataset.key, resolved); + return resolved; +} + export async function resolveFilterOptions( filters: ExportFilterDef[], ds: DataSource, diff --git a/apps/edr-freight-api/src/modules/exports/export-request.util.spec.ts b/apps/edr-freight-api/src/modules/exports/export-request.util.spec.ts index 1b473b12a..58724298c 100644 --- a/apps/edr-freight-api/src/modules/exports/export-request.util.spec.ts +++ b/apps/edr-freight-api/src/modules/exports/export-request.util.spec.ts @@ -2,6 +2,7 @@ import { EXPORT_MIME, formatRowCap, pickByKey, + pickDatasetFields, resolveExportFormat, resolveRowLimit, } from './export-request.util'; @@ -85,3 +86,48 @@ describe('pickByKey', () => { expect(pickByKey(columns, 'ghost,also-ghost')).toEqual(columns); }); }); + +describe('pickDatasetFields', () => { + const fields = [ + { key: 'name', label: 'Company', default: true }, + { key: 'tin', label: 'TIN', default: true }, + { key: 'website', label: 'Website' }, + { key: 'kebele', label: 'Kebele' }, + ]; + const defaults = [fields[0], fields[1]]; + + it.each([undefined, '', ' ', ','])('%p means the default fields', (raw) => { + expect(pickDatasetFields(fields, raw)).toEqual(defaults); + }); + + it('a subset is honoured, in the dataset\'s own order', () => { + expect(pickDatasetFields(fields, 'kebele,name')).toEqual([fields[0], fields[3]]); + }); + + it('asking for EVERY field exports every field', () => { + // The dialog's "All columns" chip sends exactly this. Falling back to the + // defaults here was the bug: 37 ticked customer columns exported as 9. + expect(pickDatasetFields(fields, 'name,tin,website,kebele')).toEqual(fields); + }); + + it('a non-default field alone is not widened back to the defaults', () => { + expect(pickDatasetFields(fields, 'website')).toEqual([fields[2]]); + }); + + it('unknown keys are dropped, the recognised ones still stand', () => { + expect(pickDatasetFields(fields, 'ghost,website')).toEqual([fields[2]]); + }); + + it('all-unknown keys fall back to the defaults, not to everything', () => { + expect(pickDatasetFields(fields, 'ghost,also-ghost')).toEqual(defaults); + }); + + it('surrounding whitespace in a hand-built fields list is tolerated', () => { + expect(pickDatasetFields(fields, ' name , website ')).toEqual([fields[0], fields[2]]); + }); + + it('a dataset with no default flags falls back to every field', () => { + const flat = [{ key: 'a', label: 'A' }, { key: 'b', label: 'B' }]; + expect(pickDatasetFields(flat, undefined)).toEqual(flat); + }); +}); diff --git a/apps/edr-freight-api/src/modules/exports/export-request.util.ts b/apps/edr-freight-api/src/modules/exports/export-request.util.ts index 39f1f2e8c..5b57c82f0 100644 --- a/apps/edr-freight-api/src/modules/exports/export-request.util.ts +++ b/apps/edr-freight-api/src/modules/exports/export-request.util.ts @@ -56,3 +56,31 @@ export function pickByKey(all: T[], raw: string | und const filtered = requested?.length ? all.filter((c) => requested.includes(c.key)) : all; return filtered.length ? filtered : all; } + +/** + * A dataset's requested field subset, whitelisted against what the caller may + * have. Unlike `pickByKey`, "nothing recognised" falls back to the DEFAULT + * fields rather than to every field — a bookings export declares ~70 columns + * and dumping all of them on an unparameterised call is nobody's intent. + * + * Selecting every field is a legitimate request — the dialog's "All columns" + * chip sends exactly that — so the fallback keys off whether any requested key + * MATCHED, never off how many fields came back. Comparing the picked count to + * `all.length` (as this did originally) made "All columns" silently export the + * default columns instead. + */ +export function pickDatasetFields( + all: T[], + raw: string | undefined, +): T[] { + const requested = new Set( + raw + ?.split(',') + .map((k) => k.trim()) + .filter(Boolean) ?? [], + ); + const picked = requested.size ? all.filter((f) => requested.has(f.key)) : []; + if (picked.length) return picked; + const defaults = all.filter((f) => f.default); + return defaults.length ? defaults : all; +} diff --git a/apps/edr-freight-api/src/modules/exports/export.types.ts b/apps/edr-freight-api/src/modules/exports/export.types.ts index db12f0211..71abdad88 100644 --- a/apps/edr-freight-api/src/modules/exports/export.types.ts +++ b/apps/edr-freight-api/src/modules/exports/export.types.ts @@ -103,6 +103,15 @@ export interface ExportDataset { alwaysJoin?: string[]; groups: ExportGroup[]; fields: ExportField[]; + /** + * Extra fields resolved from reference data and appended to `fields` — one + * column per row of some small, rarely-changing table (a column per container + * type, say). Cached for the process, like `ExportFilterDef.optionsQuery`. + * + * The SQL these build is interpolated, not bound, so a resolver MUST validate + * anything it splices in; see `bookingsDataset` for the uuid guard. + */ + dynamicFields?: (ds: DataSource) => Promise; filters: ExportFilterDef[]; /** Must name a field whose `sortExpr` references only the base alias. */ defaultSort?: { key: string; dir: 'ASC' | 'DESC' }; 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 aea35a9bc..808e375bc 100644 --- a/apps/edr-freight-api/src/modules/exports/exports.controller.ts +++ b/apps/edr-freight-api/src/modules/exports/exports.controller.ts @@ -9,11 +9,11 @@ import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/curre import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util'; import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service'; -import { resolveFilterOptions } from './export-filter.util'; +import { resolveDatasetFields, resolveFilterOptions } from './export-filter.util'; import { EXPORT_MIME, formatRowCap, - pickByKey, + pickDatasetFields, resolveExportFormat, resolveRowLimit, } from './export-request.util'; @@ -31,13 +31,16 @@ const CAPS = { csv: CSV_ROW_CAP, xlsx: XLSX_ROW_CAP, pdf: PDF_ROW_CAP }; * Metadata only. `select` / `requires` / `sortExpr` are raw SQL and a map of * the schema — they never leave the server. */ -const toCatalogEntry = (dataset: ExportDataset): ExportCatalogEntry => ({ +const toCatalogEntry = ( + dataset: ExportDataset, + fields: ExportField[], +): ExportCatalogEntry => ({ key: dataset.key, title: dataset.title, description: dataset.description, group: dataset.group, groups: dataset.groups, - fields: dataset.fields.map(({ key, label, type, group, default: isDefault }) => ({ + fields: fields.map(({ key, label, type, group, default: isDefault }) => ({ key, label, type, @@ -72,7 +75,7 @@ export class ExportsController { const allowed = DATASETS.filter((d) => hasFreightPermission(user, d.permission)); return Promise.all( allowed.map(async (d) => ({ - ...toCatalogEntry(d), + ...toCatalogEntry(d, await resolveDatasetFields(d, this.dataSource)), filters: await resolveFilterOptions(d.filters, this.dataSource), })), ); @@ -102,7 +105,10 @@ export class ExportsController { const dataset = this.resolve(key, user); const directions = await this.userTradeAccessService.resolveAllowedDirections(user); const format = resolveExportFormat(query.format); - const fields = this.resolveFields(dataset, query.fields); + const fields = pickDatasetFields( + await resolveDatasetFields(dataset, this.dataSource), + query.fields, + ); const rows = await this.runner.run(dataset, fields, query, directions, { cap: formatRowCap(format), @@ -129,22 +135,6 @@ export class ExportsController { res.send(buffer); } - /** - * Requested fields, whitelisted against the dataset. No `fields=` means the - * DEFAULT set, not everything — a booking export has ~70 fields and dumping - * all of them on an unparameterised call is nobody's intent. - */ - private resolveFields(dataset: ExportDataset, raw: string | undefined): ExportField[] { - if (raw?.trim()) { - const picked = pickByKey(dataset.fields, raw); - // pickByKey falls back to everything when nothing matched; for a dataset - // the safer read of "all keys unknown" is still the default set. - if (picked.length !== dataset.fields.length) return picked; - } - const defaults = dataset.fields.filter((f) => f.default); - return defaults.length ? defaults : dataset.fields; - } - private resolve(key: string, user: TCurrentUser): ExportDataset { const dataset = getDataset(key); if (!dataset) throw new NotFoundException(`Unknown export dataset: ${key}`); diff --git a/apps/edr-freight-api/src/modules/health/health.controller.spec.ts b/apps/edr-freight-api/src/modules/health/health.controller.spec.ts new file mode 100644 index 000000000..26c873567 --- /dev/null +++ b/apps/edr-freight-api/src/modules/health/health.controller.spec.ts @@ -0,0 +1,100 @@ +import 'reflect-metadata'; + +import type { Response } from 'express'; +import type { DataSource } from 'typeorm'; + +import type { MatrixClient } from '../chat/matrix.client'; +import type { EmailClientService } from '../notifications/email-client.service'; +import type { SmsClientService } from '../notifications/sms-client.service'; +import { HealthController } from './health.controller'; + +type ReadinessBody = { + status: string; + checks: { + chat: { status: string; enabled: boolean; actingAs?: string; error?: string }; + }; +}; + +/** Captures what the controller wrote, in place of an express Response. */ +function recorder() { + const sent: { code?: number; body?: ReadinessBody } = {}; + const res = { + status(code: number) { + sent.code = code; + return this; + }, + json(body: ReadinessBody) { + sent.body = body; + return this; + }, + }; + return { sent, res: res as unknown as Response }; +} + +function controllerWith(matrix: Partial) { + const dataSource = { query: jest.fn(async () => [{ '?column?': 1 }]) }; + return new HealthController( + dataSource as unknown as DataSource, + { brokerConnected: true } as unknown as SmsClientService, + { brokerConnected: true } as unknown as EmailClientService, + matrix as MatrixClient, + ); +} + +describe('HealthController readiness — chat check', () => { + it('reports degraded, not 503, when MATRIX_ADMIN_TOKEN is not a server admin', async () => { + // The dev outage. Chat is broken, but chat is not worth pulling the pod + // out of the load balancer for — bookings and billing still work. + const controller = controllerWith({ + enabled: true, + adminCheck: jest.fn(async () => ({ + ok: false, + actingAs: '@super-admin.f15347:matrixdev.edrsc.com', + error: 'Matrix GET /_synapse/admin/v2/users?limit=1 -> 403: not a server admin', + })), + }); + + const { sent, res } = recorder(); + await controller.readiness(res); + + expect(sent.code).toBe(200); + expect(sent.body?.status).toBe('degraded'); + expect(sent.body?.checks.chat.status).toBe('error'); + // The account name is the actionable half — it says *which* token is wired up. + expect(sent.body?.checks.chat.actingAs).toBe( + '@super-admin.f15347:matrixdev.edrsc.com', + ); + }); + + it('reports ok when the token really is a server admin', async () => { + const controller = controllerWith({ + enabled: true, + adminCheck: jest.fn(async () => ({ + ok: true, + actingAs: '@edrbot:matrixdev.edrsc.com', + })), + }); + + const { sent, res } = recorder(); + await controller.readiness(res); + + expect(sent.body?.status).toBe('ok'); + expect(sent.body?.checks.chat).toMatchObject({ + status: 'ok', + enabled: true, + actingAs: '@edrbot:matrixdev.edrsc.com', + }); + }); + + it('does not call Synapse, or degrade, when chat is switched off', async () => { + const adminCheck = jest.fn(); + const controller = controllerWith({ enabled: false, adminCheck }); + + const { sent, res } = recorder(); + await controller.readiness(res); + + expect(adminCheck).not.toHaveBeenCalled(); + expect(sent.body?.status).toBe('ok'); + expect(sent.body?.checks.chat).toEqual({ status: 'unknown', enabled: false }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/health/health.controller.ts b/apps/edr-freight-api/src/modules/health/health.controller.ts index 6b559f2e3..94b8eb698 100644 --- a/apps/edr-freight-api/src/modules/health/health.controller.ts +++ b/apps/edr-freight-api/src/modules/health/health.controller.ts @@ -7,6 +7,7 @@ import { Public } from "@edr/api-common"; import { Response } from "express"; import { DataSource } from "typeorm"; +import { MatrixClient } from "../chat/matrix.client"; import { EmailClientService } from "../notifications/email-client.service"; import { SmsClientService } from "../notifications/sms-client.service"; @@ -32,6 +33,7 @@ export class HealthController { private readonly dataSource: DataSource, private readonly smsClient: SmsClientService, private readonly emailClient: EmailClientService, + private readonly matrix: MatrixClient, ) {} @Get() @@ -45,7 +47,7 @@ export class HealthController { @Public() @ApiOperation({ summary: - "Readiness probe — database plus SMS/email broker connectivity. Broker failures report as degraded unless READINESS_REQUIRES_BROKER=true.", + "Readiness probe — database, SMS/email broker connectivity, and the Matrix admin token. Broker failures report as degraded unless READINESS_REQUIRES_BROKER=true; chat failures always report as degraded.", }) async readiness(@Res() res: Response) { const startedAt = Date.now(); @@ -76,23 +78,55 @@ export class HealthController { enabled: process.env.RABBITMQ_ENABLED !== "false", }; + const chat = await this.chatCheck(); + const brokerDown = broker.sms.status === "error" || broker.email.status === "error"; const failed = database.status === "error" || (READINESS_REQUIRES_BROKER && brokerDown); - const status = failed ? "error" : brokerDown ? "degraded" : "ok"; + const status = failed + ? "error" + : brokerDown || chat.status === "error" + ? "degraded" + : "ok"; return res .status(failed ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.OK) .json({ status, timestamp: new Date().toISOString(), - checks: { database, broker }, + checks: { database, broker, chat }, }); } + /** + * Chat provisioning runs entirely on MATRIX_ADMIN_TOKEN, and a token that is + * valid but not *server admin* fails only the `/_synapse/admin` half: rooms + * are never created, joins never happen, and the sole symptom is an empty + * Element for every employee. Nothing else in the probe would catch that. + * + * Degraded, never a 503 — chat is not worth pulling the pod out of the load + * balancer for, by the same reasoning as the broker check above. `unknown` + * when MATRIX_ENABLED is off: a feature that is switched off is not a fault. + */ + private async chatCheck(): Promise<{ + status: CheckStatus; + enabled: boolean; + actingAs?: string; + error?: string; + }> { + if (!this.matrix.enabled) return { status: "unknown", enabled: false }; + const check = await this.matrix.adminCheck(); + return { + status: check.ok ? "ok" : "error", + enabled: true, + actingAs: check.actingAs, + error: check.error, + }; + } + @Get("info") @Public() @ApiOperation({ summary: "App info — version, environment, uptime" }) diff --git a/apps/edr-freight-api/src/modules/health/health.module.ts b/apps/edr-freight-api/src/modules/health/health.module.ts index 572e5eb86..1aa74f56f 100644 --- a/apps/edr-freight-api/src/modules/health/health.module.ts +++ b/apps/edr-freight-api/src/modules/health/health.module.ts @@ -2,13 +2,15 @@ import { Module } from "@nestjs/common"; +import { ChatModule } from "../chat/chat.module"; import { HealthController } from "./health.controller"; import { NotificationsModule } from "../notifications/notifications.module"; @Module({ // NotificationsModule exports the SMS/email clients; the readiness probe reads // their broker connection state rather than opening a second connection. - imports: [NotificationsModule], + // ChatModule exports MatrixClient for the MATRIX_ADMIN_TOKEN check. + imports: [NotificationsModule, ChatModule], controllers: [HealthController], }) export class HealthModule {} diff --git a/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts b/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts index 9af97f1e8..92a5a611a 100644 --- a/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts +++ b/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts @@ -1,6 +1,8 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { + ArrayMaxSize, + ArrayMinSize, ArrayNotEmpty, IsArray, IsDateString, @@ -9,6 +11,7 @@ import { IsOptional, IsString, IsUUID, + MaxLength, Min, ValidateNested, } from 'class-validator'; @@ -150,6 +153,14 @@ export class CreateEmptyContainerReturnDto { @IsUUID() customerId?: string; + @ApiPropertyOptional({ + description: 'Owning company name — free text when the company is not a registered customer.', + }) + @IsOptional() + @IsString() + @MaxLength(200) + companyName?: string; + @ApiPropertyOptional() @IsOptional() @IsDateString() @@ -196,6 +207,16 @@ export class CreateEmptyContainerReturnDto { returnedBy?: 'EDR' | 'CUSTOMER'; } +export class BulkCreateEmptyContainerReturnsDto { + @ApiProperty({ type: [CreateEmptyContainerReturnDto] }) + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(1000) + @ValidateNested({ each: true }) + @Type(() => CreateEmptyContainerReturnDto) + returns!: CreateEmptyContainerReturnDto[]; +} + export class LoadEmptyContainerItemDto { @ApiProperty({ format: 'uuid' }) @IsUUID() diff --git a/apps/edr-freight-api/src/modules/import-operations/empty-return-bookings.util.spec.ts b/apps/edr-freight-api/src/modules/import-operations/empty-return-bookings.util.spec.ts new file mode 100644 index 000000000..848732f47 --- /dev/null +++ b/apps/edr-freight-api/src/modules/import-operations/empty-return-bookings.util.spec.ts @@ -0,0 +1,98 @@ +import { + assembleEmptyReturnBookings, + type EmptyReturnBookingUnitRow, +} from './empty-return-bookings.util'; + +const booking = { + bookingId: 'b1', + bookingReference: 'BK-2026-000263', + bookingStatus: 'IN_TRANSIT', + equipmentReturn: 'WITH_RETURN', + customerId: 'c1', + companyName: 'Afri Software Solutions', +}; + +const unit = ( + overrides: Partial & { unitId: string; containerNumber: string }, +): EmptyReturnBookingUnitRow => ({ + ...booking, + containerSize: '40ft', + containerType: '40FT', + returnId: null, + returnStatus: null, + ...overrides, +}); + +describe('assembleEmptyReturnBookings', () => { + it('groups a booking’s flagged containers onto one row, all pending', () => { + const rows = assembleEmptyReturnBookings([ + unit({ unitId: 'u1', containerNumber: 'MSFH8596324' }), + unit({ unitId: 'u2', containerNumber: 'SDJU8596324' }), + ]); + + expect(rows).toHaveLength(1); + expect(rows[0].bookingReference).toBe('BK-2026-000263'); + expect(rows[0].companyName).toBe('Afri Software Solutions'); + expect(rows[0].containers.map((c) => c.containerNumber)).toEqual([ + 'MSFH8596324', + 'SDJU8596324', + ]); + expect(rows[0]).toMatchObject({ expectedCount: 2, recordedCount: 0, pendingCount: 2 }); + }); + + it('keeps an already-recorded container visible but out of the pending count', () => { + const rows = assembleEmptyReturnBookings([ + unit({ + unitId: 'u1', + containerNumber: 'MSFH8596324', + returnId: 'r1', + returnStatus: 'ASSIGNED_STORAGE', + }), + unit({ unitId: 'u2', containerNumber: 'SDJU8596324' }), + ]); + + expect(rows[0]).toMatchObject({ expectedCount: 2, recordedCount: 1, pendingCount: 1 }); + expect(rows[0].containers[0].returnStatus).toBe('ASSIGNED_STORAGE'); + }); + + it('drops a booking once every container is recorded', () => { + const rows = assembleEmptyReturnBookings([ + unit({ + unitId: 'u1', + containerNumber: 'MSFH8596324', + returnId: 'r1', + returnStatus: 'RETURNED', + }), + unit({ + unitId: 'u2', + containerNumber: 'SDJU8596324', + returnId: 'r2', + returnStatus: 'COMPLETED', + }), + ]); + + expect(rows).toEqual([]); + }); + + it('keeps each booking on its own row, in query order', () => { + const other = { + ...booking, + bookingId: 'b2', + bookingReference: 'BK-2026-000286', + companyName: 'DE BE KE', + }; + const rows = assembleEmptyReturnBookings([ + unit({ unitId: 'u1', containerNumber: 'MSFH8596324' }), + { ...unit({ unitId: 'u2', containerNumber: 'ASDS1234567' }), ...other }, + unit({ unitId: 'u3', containerNumber: 'SDJU8596324' }), + ]); + + expect(rows.map((r) => r.bookingReference)).toEqual(['BK-2026-000263', 'BK-2026-000286']); + expect(rows[0].containers).toHaveLength(2); + expect(rows[1].containers).toHaveLength(1); + }); + + it('returns nothing when no booking owes an empty', () => { + expect(assembleEmptyReturnBookings([])).toEqual([]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/import-operations/empty-return-bookings.util.ts b/apps/edr-freight-api/src/modules/import-operations/empty-return-bookings.util.ts new file mode 100644 index 000000000..e2f56df9d --- /dev/null +++ b/apps/edr-freight-api/src/modules/import-operations/empty-return-bookings.util.ts @@ -0,0 +1,107 @@ +import type { EmptyContainerReturnStatus } from './entities/empty-container-return.entity'; + +/** + * `WITH_RETURN` is the current value; `RETURN` is what older bookings were + * written with. Both mean the same thing — the booking owes empties back. + */ +export const WITH_RETURN_EQUIPMENT_VALUES = ['WITH_RETURN', 'RETURN']; + +/** Bookings in these statuses never ship, so they never owe an empty back. */ +export const EMPTY_RETURN_CLOSED_BOOKING_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED']; + +/** + * One flagged return container of a booking, as the query hands it over: the + * booking columns repeat on every row, and `returnId` is set when this exact + * container already has an empty return recorded against the booking. + */ +export interface EmptyReturnBookingUnitRow { + bookingId: string; + bookingReference: string; + bookingStatus: string; + equipmentReturn: string; + customerId: string | null; + companyName: string | null; + unitId: string; + containerNumber: string; + containerSize: string | null; + containerType: string | null; + returnId: string | null; + returnStatus: EmptyContainerReturnStatus | null; +} + +/** One container a booking owes back empty. */ +export interface EmptyReturnBookingContainer { + /** Stable row key — the booking container unit id. */ + key: string; + unitId: string; + containerNumber: string; + containerSize: string | null; + containerType: string | null; + /** Set once the empty return for this container has been recorded. */ + returnId: string | null; + returnStatus: EmptyContainerReturnStatus | null; +} + +/** A booking that ships with empty-container return and still owes empties. */ +export interface EmptyReturnBookingRow { + bookingId: string; + bookingReference: string; + bookingStatus: string; + equipmentReturn: string; + customerId: string | null; + companyName: string | null; + containers: EmptyReturnBookingContainer[]; + expectedCount: number; + recordedCount: number; + pendingCount: number; +} + +/** + * Groups a booking's flagged return containers onto one row per booking. + * + * A container whose empty return is already recorded keeps its row — the + * screen shows what has been done — but stops counting as pending, and a + * booking with nothing left pending drops off the list entirely. + * + * Row order follows the query (newest booking first, containers in booking + * order), so the caller decides the ordering, not this function. + */ +export function assembleEmptyReturnBookings( + units: EmptyReturnBookingUnitRow[], +): EmptyReturnBookingRow[] { + const rows = new Map(); + + for (const unit of units) { + const row = rows.get(unit.bookingId) ?? { + bookingId: unit.bookingId, + bookingReference: unit.bookingReference, + bookingStatus: unit.bookingStatus, + equipmentReturn: unit.equipmentReturn, + customerId: unit.customerId, + companyName: unit.companyName, + containers: [], + expectedCount: 0, + recordedCount: 0, + pendingCount: 0, + }; + row.containers.push({ + key: unit.unitId, + unitId: unit.unitId, + containerNumber: unit.containerNumber, + containerSize: unit.containerSize, + containerType: unit.containerType, + returnId: unit.returnId, + returnStatus: unit.returnStatus, + }); + rows.set(unit.bookingId, row); + } + + return [...rows.values()] + .map((row) => ({ + ...row, + expectedCount: row.containers.length, + recordedCount: row.containers.filter((container) => container.returnId).length, + pendingCount: row.containers.filter((container) => !container.returnId).length, + })) + .filter((row) => row.pendingCount > 0); +} diff --git a/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts b/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts index 263dd30d2..230581dd4 100644 --- a/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts +++ b/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts @@ -27,6 +27,15 @@ export class EmptyContainerReturn extends BaseEntity { @Column({ name: 'customer_id', type: 'uuid', nullable: true }) customerId?: string | null; + /** + * Owning company as text. Set when the box was backfilled for a company that + * is not (yet) a registered customer, so `customer_id` cannot carry it. When + * a registered company IS picked, both are set — the name is the label the + * list renders without a join. + */ + @Column({ name: 'company_name', type: 'varchar', length: 200, nullable: true }) + companyName?: string | null; + @Column({ name: 'return_date', type: 'timestamptz' }) returnDate!: Date; @@ -75,3 +84,14 @@ export class EmptyContainerReturn extends BaseEntity { performedBy: string | null; }>; } + +/** + * A row of the returns list: the entity's own columns plus the booking + * reference and owning company joined in. Standalone returns leave + * `bookingId`/`bookingReference` null. + */ +export interface EmptyContainerReturnListItem + extends Omit { + bookingReference: string | null; + createdAt: Date; +} diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts index ae80ea9c8..53875d64d 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts @@ -11,6 +11,7 @@ import { BookingsService } from '../bookings/bookings.service'; import { AssignCustomsRiskDto, CreateDjiboutiIncidentDto, + BulkCreateEmptyContainerReturnsDto, CreateEmptyContainerReturnDto, ImportOperationActionDto, LoadEmptyContainersOnTrainDto, @@ -118,6 +119,15 @@ export class ImportOperationsController { return this.service.listEmptyReturns(); } + @Get('empty-return-bookings') + @BookingStaff(FREIGHT_PERMS.bookings.operations) + @ApiOperation({ + summary: 'Bookings shipping with empty-container return that still owe empties, with their containers', + }) + listEmptyReturnBookings() { + return this.service.listEmptyReturnBookings(); + } + @Post('empty-container-returns') @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 16: create an empty container return record' }) @@ -125,6 +135,15 @@ export class ImportOperationsController { return this.service.createEmptyReturn(dto); } + @Post('empty-container-returns/bulk') + @BookingStaff(FREIGHT_PERMS.bookings.operations) + @ApiOperation({ + summary: 'Bulk-record empties already in the yard but never entered in the system', + }) + bulkCreateEmptyReturns(@Body() dto: BulkCreateEmptyContainerReturnsDto) { + return this.service.bulkCreateEmptyReturns(dto); + } + @Post('empty-container-returns/load-on-train') @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts index 8cdc83853..d6cb00fd2 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BookingsModule } from '../bookings/bookings.module'; +import { EmptyReturnRequestsModule } from '../empty-return-requests/empty-return-requests.module'; import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { WarehousesModule } from '../warehouses/warehouses.module'; @@ -26,6 +27,9 @@ import { ImportOperationsService } from './import-operations.service'; BookingsModule, NotificationInboxModule, NotificationsModule, + // Recording a return is what closes out the customer's scheduled empty + // return request, once every container on it is back. + EmptyReturnRequestsModule, ], controllers: [ImportOperationsController], providers: [ImportOperationsService], diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts index be94e3f32..6333af97e 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts @@ -1,6 +1,6 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { In, Repository } from 'typeorm'; +import { In, Not, Repository } from 'typeorm'; import { NotificationAudience, NotificationType } from '@edr/types'; import { LogoSettingsService } from '../logo-settings/logo-settings.service'; @@ -8,8 +8,10 @@ import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util' import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationsService } from '../notifications/notifications.service'; import { sendCompanyChannels } from '../notifications/notify-company.util'; +import { EmptyReturnRequestsService } from '../empty-return-requests/empty-return-requests.service'; import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service'; import { + BulkCreateEmptyContainerReturnsDto, CreateDjiboutiIncidentDto, CreateEmptyContainerReturnDto, ImportOperationActionDto, @@ -24,7 +26,18 @@ import { type DjiboutiIncidentType, } from './entities/djibouti-incident.entity'; import { assertWagonLoad } from './empty-container-wagon.util'; -import { EmptyContainerReturn } from './entities/empty-container-return.entity'; +import { + assembleEmptyReturnBookings, + EMPTY_RETURN_CLOSED_BOOKING_STATUSES, + WITH_RETURN_EQUIPMENT_VALUES, + type EmptyReturnBookingRow, + type EmptyReturnBookingUnitRow, +} from './empty-return-bookings.util'; +import { + EmptyContainerReturn, + type EmptyContainerReturnListItem, + type EmptyContainerReturnStatus, +} from './entities/empty-container-return.entity'; import { ImportCustomsFinalization, type ImportCustomsDocumentType, @@ -52,6 +65,7 @@ export class ImportOperationsService { private readonly logoSettings: LogoSettingsService, private readonly inbox: NotificationInboxService, private readonly notifications: NotificationsService, + private readonly emptyReturnRequests: EmptyReturnRequestsService, ) {} listIncidents(bookingId?: string) { @@ -159,14 +173,94 @@ export class ImportOperationsService { return this.getCustoms(bookingId); } - listEmptyReturns() { - return this.emptyReturns.find({ order: { createdAt: 'DESC' } as never }); + /** + * Every empty return, booking-linked and standalone alike, in one list. The + * booking reference and the owning company are joined in so the table can + * show which booking a box came back on without a second round trip — a + * standalone row simply has neither, and falls back to the typed + * `company_name`. + */ + listEmptyReturns(): Promise { + return this.emptyReturns.manager.query(` + SELECT + r.id, + r.container_number AS "containerNumber", + r.booking_id AS "bookingId", + b.reference AS "bookingReference", + r.customer_id AS "customerId", + COALESCE(r.company_name, c.name) AS "companyName", + r.return_date AS "returnDate", + r.facility, + r.yard, + r.zone, + r.condition, + r.handover_note AS "handoverNote", + r.status, + r.wagon_allocation_reference AS "wagonAllocationReference", + r.container_size AS "containerSize", + r.train_schedule_id AS "trainScheduleId", + r.wagon_sequence_no AS "wagonSequenceNo", + r.performed_by AS "performedBy", + r.returned_by AS "returnedBy", + r.status_history AS "statusHistory", + r.created_at AS "createdAt" + FROM freight.empty_container_returns r + LEFT JOIN freight.bookings b ON b.id = r.booking_id + LEFT JOIN freight.companies c ON c.id = b.company_id + WHERE r.deleted_at IS NULL + ORDER BY r.created_at DESC + `); } listEmptyReturnsForBooking(bookingId: string) { return this.emptyReturns.find({ where: { bookingId }, order: { createdAt: 'DESC' } as never }); } + /** + * Bookings that ship WITH empty-container return and still owe empties, each + * with the containers that are to be returned — the ones the booking flagged + * `is_return`, carrying the empty return already recorded against each, if + * any. + */ + async listEmptyReturnBookings(): Promise { + const units: EmptyReturnBookingUnitRow[] = await this.emptyReturns.manager.query( + `SELECT b.id AS "bookingId", + b.reference AS "bookingReference", + b.status AS "bookingStatus", + b.equipment_return AS "equipmentReturn", + b.company_id AS "customerId", + c.name AS "companyName", + u.id AS "unitId", + u.container_number AS "containerNumber", + COALESCE(bc.container_size, ct.code) AS "containerSize", + ct.label AS "containerType", + r.id AS "returnId", + r.status AS "returnStatus" + FROM freight.booking_container_units u + JOIN freight.booking_container bc ON bc.id = u.booking_container_id AND bc.deleted_at IS NULL + JOIN freight.bookings b ON b.id = bc.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies c ON c.id = b.company_id + LEFT JOIN freight.container_types ct ON ct.id = bc.container_type_id + LEFT JOIN LATERAL ( + SELECT er.id, er.status + FROM freight.empty_container_returns er + WHERE er.deleted_at IS NULL + AND er.booking_id = b.id + AND upper(er.container_number) = upper(u.container_number) + ORDER BY er.created_at DESC + LIMIT 1 + ) r ON TRUE + WHERE u.deleted_at IS NULL + AND u.is_return = true + AND b.equipment_return = ANY($1) + AND b.status <> ALL($2) + ORDER BY b.created_at DESC, u.sort_order ASC`, + [WITH_RETURN_EQUIPMENT_VALUES, EMPTY_RETURN_CLOSED_BOOKING_STATUSES], + ); + + return assembleEmptyReturnBookings(units); + } + async createEmptyReturn(dto: CreateEmptyContainerReturnDto) { const returnDate = dto.returnDate ? new Date(dto.returnDate) : new Date(); const saved = await this.emptyReturns.save( @@ -174,6 +268,7 @@ export class ImportOperationsService { containerNumber: dto.containerNumber, bookingId: dto.bookingId ?? null, customerId: dto.customerId ?? null, + companyName: dto.companyName ?? null, returnDate, containerSize: dto.containerSize ?? null, facility: dto.facility ?? null, @@ -195,10 +290,72 @@ export class ImportOperationsService { // Standalone returns (no booking) have no company to notify. if (saved.bookingId) { await this.notifyEquipmentInterchangeReady(saved); + // Closes the customer's scheduled request once its last container is in. + await this.emptyReturnRequests.settleScheduledForBooking(saved.bookingId); } return saved; } + /** + * Bulk backfill of empties already sitting in a yard but never recorded. + * All-or-nothing: if any container number already has an open (not COMPLETED) + * return, nothing is written — re-uploading the same sheet must not duplicate + * boxes. No interchange notification is sent; these are historical rows, not + * a live handover. + */ + async bulkCreateEmptyReturns(dto: BulkCreateEmptyContainerReturnsDto) { + const numbers = dto.returns.map((r) => r.containerNumber.trim().toUpperCase()); + + const seen = new Set(); + const dupInFile = numbers.filter((n) => (seen.has(n) ? true : (seen.add(n), false))); + if (dupInFile.length > 0) { + throw new BadRequestException( + `Container number(s) repeated in the upload: ${[...new Set(dupInFile)].join(', ')}`, + ); + } + + const existing = await this.emptyReturns.find({ + where: { + containerNumber: In(numbers), + status: Not('COMPLETED' as EmptyContainerReturnStatus), + }, + select: { containerNumber: true }, + }); + if (existing.length > 0) { + throw new BadRequestException( + `Already recorded as returned: ${existing.map((r) => r.containerNumber).join(', ')}`, + ); + } + + const rows = dto.returns.map((r, i) => { + const returnDate = r.returnDate ? new Date(r.returnDate) : new Date(); + return this.emptyReturns.create({ + containerNumber: numbers[i], + bookingId: r.bookingId ?? null, + customerId: r.customerId ?? null, + companyName: r.companyName ?? null, + returnDate, + containerSize: r.containerSize ?? null, + facility: r.facility ?? null, + yard: r.yard ?? null, + zone: r.zone ?? null, + condition: r.condition ?? null, + handoverNote: r.handoverNote ?? null, + performedBy: r.performedBy ?? null, + returnedBy: r.returnedBy ?? null, + statusHistory: [ + { + status: 'RETURNED' as const, + changedAt: returnDate.toISOString(), + performedBy: r.performedBy ?? null, + }, + ], + }); + }); + + return this.emptyReturns.save(rows); + } + /** * Load returned empties onto an export departure. A wagon takes ONE 40ft or * TWO 20ft — never a mix, never three. Empties already sitting on a wagon of diff --git a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts index 467b172ba..1dd8ad9ca 100644 --- a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts +++ b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts @@ -83,3 +83,184 @@ export async function notifyCarriageAcceptanceReady( logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`); } } + +/** One container line on the load manifest notice. */ +interface LoadManifestLists { + reference: string; + companyId: string | null; + trainNumber: string | null; + originStation: string | null; + destinationStation: string | null; + departureAt: Date | null; + loaded: string[]; + leftBehind: string[]; +} + +/** At most `max` numbers, then "+N more" — an SMS must not carry 44 of them. */ +function summarizeNumbers(numbers: string[], max = 5): string { + if (numbers.length === 0) return 'none'; + const shown = numbers.slice(0, max).join(', '); + const rest = numbers.length - max; + return rest > 0 ? `${shown} +${rest} more` : shown; +} + +/** + * Read what actually went on the train and what did not. Left behind = every + * container the customer declared minus the ones sitting on a LOADED/DEPARTED + * wagon, so a booking loaded in parts reports honestly on both halves. + */ +export async function loadManifestLists( + dataSource: DataSource, + bookingId: string, + trainScheduleId: string, +): Promise { + const [booking]: Array<{ reference: string; companyId: string | null }> = + await dataSource.query( + `SELECT reference, company_id AS "companyId" + FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!booking) return null; + + const [train]: Array<{ + trainNumber: string | null; + originStation: string | null; + destinationStation: string | null; + departureAt: Date | null; + }> = await dataSource.query( + `SELECT s.train_number AS "trainNumber", + so.label AS "originStation", + sd.label AS "destinationStation", + s.scheduled_departure_date AS "departureAt" + FROM freight.train_schedules s + LEFT JOIN freight.yards so ON so.id = s.origin_station_id + LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id + WHERE s.id = $1 AND s.deleted_at IS NULL`, + [trainScheduleId], + ); + + const loadedRows: Array<{ containerNumber: string | null }> = await dataSource.query( + `SELECT DISTINCT ci.container_number AS "containerNumber" + FROM freight.wagon_allocation_container_items ci + JOIN freight.wagon_booking_allocations a + ON a.id = ci.wagon_booking_allocation_id AND a.deleted_at IS NULL + WHERE a.booking_id = $1 + AND ci.deleted_at IS NULL + AND a.status IN ('LOADED', 'DEPARTED') + ORDER BY 1`, + [bookingId], + ); + const declaredRows: Array<{ containerNumber: string | null }> = await dataSource.query( + `SELECT DISTINCT u.container_number AS "containerNumber" + FROM freight.booking_container_units u + JOIN freight.booking_container l + ON l.id = u.booking_container_id AND l.deleted_at IS NULL + WHERE l.booking_id = $1 AND u.deleted_at IS NULL + ORDER BY 1`, + [bookingId], + ); + + const loaded = loadedRows.map((r) => r.containerNumber).filter(Boolean) as string[]; + const loadedSet = new Set(loaded); + const leftBehind = (declaredRows.map((r) => r.containerNumber).filter(Boolean) as string[]).filter( + (n) => !loadedSet.has(n), + ); + + return { + reference: booking.reference, + companyId: booking.companyId, + trainNumber: train?.trainNumber ?? null, + originStation: train?.originStation ?? null, + destinationStation: train?.destinationStation ?? null, + departureAt: train?.departureAt ?? null, + loaded, + leftBehind, + }; +} + +/** + * Tell the customer what boarded the train and what did not, over in-app + SMS + * + email, and raise a warehouse-desk notice for anything left behind so + * somebody owns finding it space. A booking is routinely loaded in parts, and + * before this the customer learnt about it only by reading the sheet. + * + * Best-effort throughout: loading must never roll back because a provider is + * down. + */ +export async function notifyLoadManifest( + dataSource: DataSource, + notifications: NotificationsService, + inbox: NotificationInboxService, + bookingId: string, + trainScheduleId: string, + warehouseNotificationPermission: string, + logger: Logger, +): Promise { + try { + const m = await loadManifestLists(dataSource, bookingId, trainScheduleId); + if (!m) return; + + const route = + m.originStation && m.destinationStation + ? ` ${m.originStation} → ${m.destinationStation}` + : ''; + const departs = m.departureAt + ? `, departs ${new Date(m.departureAt).toLocaleString('en-GB')}` + : ''; + const train = m.trainNumber ? `train ${m.trainNumber}` : 'the train'; + + const headline = + `Booking ${m.reference}: ${m.loaded.length} container(s) loaded on ${train}` + + `${route}${departs}.`; + const loadedLine = m.loaded.length > 0 ? ` Loaded: ${summarizeNumbers(m.loaded)}.` : ''; + const leftLine = + m.leftBehind.length > 0 + ? ` Not loaded (${m.leftBehind.length}): ${summarizeNumbers(m.leftBehind)}.` + + ' These stay with EDR — once a warehouse is assigned you will receive the GRN.' + : ''; + const body = headline + loadedLine + leftLine; + + if (m.companyId) { + await inbox.notify({ + recipients: { companyId: m.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: m.leftBehind.length > 0 ? 'Cargo partly loaded' : 'Cargo loaded', + // The in-app copy carries every number; SMS and email get the summary. + body: + headline + + (m.loaded.length > 0 ? `\nLoaded: ${m.loaded.join(', ')}` : '') + + (m.leftBehind.length > 0 + ? `\nNot loaded: ${m.leftBehind.join(', ')}\nThese stay with EDR — once a warehouse is assigned you will receive the GRN.` + : ''), + link: `/bookings/${bookingId}`, + data: { + bookingId, + reference: m.reference, + trainNumber: m.trainNumber, + loaded: m.loaded, + leftBehind: m.leftBehind, + }, + }); + await sendCompanyChannels(dataSource, notifications, m.companyId, body); + } + + // Nothing left behind is nothing for the warehouse desk to place. + if (m.leftBehind.length > 0) { + await inbox.notify({ + recipients: { permissionKeys: [warehouseNotificationPermission] }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.REQUEST_SUBMITTED, + title: `${m.leftBehind.length} container(s) left behind — ${m.reference}`, + body: + `${train} departed without ${m.leftBehind.length} container(s) of booking ${m.reference}: ` + + `${m.leftBehind.join(', ')}. Assign warehouse space and raise the GRN.`, + link: `/dashboard/booking-requests/${bookingId}`, + data: { bookingId, reference: m.reference, leftBehind: m.leftBehind }, + }); + } + } catch (err) { + logger.warn(`Load manifest notify failed for ${bookingId}: ${(err as Error).message}`); + } +} 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/booking-status-breakdown.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts index 449c47181..c70fcb13f 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts @@ -1,6 +1,7 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; import { BookingStatus } from '@edr/types'; +import { bookingTonsSql } from '../../bookings/booking-tons.sql'; import { Booking } from '../../bookings/entities/booking.entity'; import { Yard } from '../../rule-engine/entities/yard.entity'; import { CargoType } from '../../rule-engine/entities/cargo-type.entity'; @@ -9,7 +10,7 @@ import { ReportContext, ReportDefinition } from '../report.types'; // One resolver behind "Booking per status, per port/train/date/cargo/contract // type" — the same breakdown Operation, Marketing, Global Logistics and the // Operation Report each ask for verbatim. Embed once, reuse everywhere. -const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; +const TONS = bookingTonsSql('b'); const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)'; const STATUS_OPTIONS = [...new Set(Object.values(BookingStatus))].map((v) => ({ diff --git a/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts index ee3063ee1..2df862f3c 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts @@ -1,9 +1,10 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; +import { bookingTonsSql } from '../../bookings/booking-tons.sql'; import { Booking } from '../../bookings/entities/booking.entity'; import { ReportContext, ReportDefinition } from '../report.types'; -const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; +const TONS = bookingTonsSql('b'); const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')"; const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED']; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts index 747a14891..64598fa87 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts @@ -1,10 +1,11 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; +import { bookingTonsSql } from '../../bookings/booking-tons.sql'; import { Company } from '../../companies/entities/company.entity'; import { Contract } from '../../contracts/entities/contract.entity'; import { ReportContext, ReportDefinition } from '../report.types'; -const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; +const TONS = bookingTonsSql('b'); const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED']; function baseQuery(ctx: ReportContext): SelectQueryBuilder { 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/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 { @@ -294,9 +295,9 @@ export class SchedulingRescheduleService { const booking = await this.loadBookingForNotify(bookingId); if (!booking) continue; if (isMaintenance) { - this.notifier.maintenanceMoved(booking, newDeparture); + this.notifier.maintenanceMoved(booking, newDeparture, scheduleId, dto.reason); } else { - this.notifier.rescheduled(booking, newDeparture); + this.notifier.rescheduled(booking, newDeparture, scheduleId, dto.reason); } } } @@ -307,7 +308,8 @@ export class SchedulingRescheduleService { for (const bookingId of dto.displacedBookingIds) { const booking = await this.loadBookingForNotify(bookingId); if (!booking) continue; - this.notifier.removedFromTrain(booking); + // Displaced bookings no longer point at the schedule — pass it explicitly. + this.notifier.removedFromTrain(booking, scheduleId); } } } diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-run-label.util.spec.ts b/apps/edr-freight-api/src/modules/train-schedules/train-run-label.util.spec.ts new file mode 100644 index 000000000..dfafcee43 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/train-run-label.util.spec.ts @@ -0,0 +1,32 @@ +import { trainRunLabel } from './train-run-label.util'; + +describe('trainRunLabel', () => { + it('names the departure by the schedule train number and voyage number', () => { + expect(trainRunLabel({ trainNumber: '8001', voyageNumber: 'V-117' })).toBe( + 'train 8001 (voyage V-117)', + ); + }); + + it('drops the voyage bracket when the schedule has no voyage number', () => { + expect(trainRunLabel({ trainNumber: '8001', voyageNumber: null })).toBe('train 8001'); + expect(trainRunLabel({ trainNumber: '8001', voyageNumber: ' ' })).toBe('train 8001'); + }); + + it('still quotes the voyage when the pool train number is not assigned yet', () => { + expect(trainRunLabel({ trainNumber: null, voyageNumber: 'V-117' })).toBe( + 'train (voyage V-117)', + ); + }); + + it('returns null when neither number is known so callers can fall back', () => { + expect(trainRunLabel({ trainNumber: null, voyageNumber: null })).toBeNull(); + expect(trainRunLabel(null)).toBeNull(); + expect(trainRunLabel(undefined)).toBeNull(); + }); + + it('capitalizes for sentence starts on request', () => { + expect( + trainRunLabel({ trainNumber: '8001', voyageNumber: 'V-117' }, { capitalize: true }), + ).toBe('Train 8001 (voyage V-117)'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-run-label.util.ts b/apps/edr-freight-api/src/modules/train-schedules/train-run-label.util.ts new file mode 100644 index 000000000..3c13506ac --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/train-run-label.util.ts @@ -0,0 +1,30 @@ +import { TrainSchedule } from './entities/train-schedule.entity'; + +export type TrainRunSource = Pick; + +/** + * How a departure is named in every customer-facing SMS / email: + * + * "train 8001 (voyage V-2026-117)" + * + * Both identifiers are the SCHEDULE's own columns — `train_schedules.train_number` + * and `train_schedules.voyage_number`. The built train (`freight.trains`) carries + * a `train_name` that the build form labels "voyage number"; that is a different + * identifier and must never be quoted to customers. Always pass the schedule. + * + * Returns null when the schedule has neither number (older rows, or an unbuilt + * departure whose pool number is assigned at dispatch) so callers can fall back + * to a generic phrase instead of printing "train (voyage)". + */ +export function trainRunLabel( + schedule: TrainRunSource | null | undefined, + opts: { capitalize?: boolean } = {}, +): string | null { + if (!schedule) return null; + const train = schedule.trainNumber?.trim() || null; + const voyage = schedule.voyageNumber?.trim() || null; + if (!train && !voyage) return null; + const head = train ? `train ${train}` : 'train'; + const label = voyage ? `${head} (voyage ${voyage})` : head; + return opts.capitalize ? label.charAt(0).toUpperCase() + label.slice(1) : label; +} 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 d9bae156a..c933a5e34 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 @@ -607,7 +607,6 @@ export class BookingBatchService implements OnModuleInit { const isBatchPaid = booking.status === "SELECTED_FOR_BATCH" || booking.status === "AWAITING_PAYMENT" || - booking.status === "PAID" || booking.paymentStatus === "PAID"; if (!isBatchPaid) return; @@ -786,7 +785,7 @@ export class BookingBatchService implements OnModuleInit { `SELECT id FROM freight.bookings WHERE deleted_at IS NULL AND train_schedule_id IS NULL - AND (payment_status = 'PAID' OR status = 'PAID') + AND payment_status = 'PAID' AND scheduled_date IS NOT NULL AND DATE(scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = $1`, [day], @@ -3476,7 +3475,7 @@ export class BookingBatchService implements OnModuleInit { schedule?.scheduledDepartureDate && eatDay(schedule.scheduledDepartureDate) !== previousDay ) { - this.notifier.allocatedOtherDay(fresh, schedule.scheduledDepartureDate); + this.notifier.allocatedOtherDay(fresh, schedule.scheduledDepartureDate, schedule); } } @@ -3819,7 +3818,6 @@ export class BookingBatchService implements OnModuleInit { fresh.trainScheduleId === scheduleId && (fresh.status === "SELECTED_FOR_BATCH" || fresh.status === "AWAITING_PAYMENT" || - fresh.status === "PAID" || fresh.paymentStatus === "PAID") ) { this.logger.debug( @@ -4535,7 +4533,7 @@ export class BookingBatchService implements OnModuleInit { manager, ); }); - this.notifier.displaced(victim); + this.notifier.displaced(victim, scheduleId); budget.add(this.needFor(victim, wagonDims), victimLeg); // Displacing frees wagons the same way an expiry does — don't leave the // schedule stuck at FULL. @@ -5489,7 +5487,6 @@ export class BookingBatchService implements OnModuleInit { ).filter( (b) => b.paymentStatus === "PAID" || - b.status === "PAID" || !payWindowLapsed(b.paymentDeadline, deadlineCutoff), ); // Export FCFS: a customer's pending operation request HOLDS its wagons from @@ -5601,7 +5598,6 @@ export class BookingBatchService implements OnModuleInit { return reserved.some( (b) => b.paymentStatus !== "PAID" && - b.status !== "PAID" && b.paymentDeadline != null && !payWindowLapsed(b.paymentDeadline, now), ); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts index ea936465e..91136ab97 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts @@ -13,6 +13,7 @@ describe('BookingJourneyService.autoPlaceOnFreedWagons', () => { { emit: jest.fn() } as never, // events {} as never, // notifications {} as never, // inbox + { record: jest.fn() } as never, // wagonHistory ); const schedule = { id: 'sched-1', trainSetId: 'ts-1' }; 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 07de05853..cc060bf4e 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 @@ -31,7 +31,10 @@ import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; -import { notifyCarriageAcceptanceReady } from '../notifications/notify-company.util'; +import { notifyCarriageAcceptanceReady,notifyLoadManifest } from '../notifications/notify-company.util'; +import { WagonEventInput, WagonHistoryService } from '../wagon-history/wagon-history.service'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; + /** * Per-booking journey along a train's corridor — for EVERY trade direction. @@ -61,12 +64,18 @@ export class BookingJourneyService { private readonly events: EventEmitter2, private readonly notifications: NotificationsService, private readonly inbox: NotificationInboxService, + private readonly wagonHistory: WagonHistoryService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, ) {} - /** Statuses from which a booking may be loaded (gov bookings don't prepay). */ + /** + * Whether a booking may be loaded. Paid is decided by the booking's + * PAYMENT status only — never by `status === 'PAID'`, which lags or is + * skipped on several flows (batch pay, manual mark-paid, gov expedite). + * Government bookings don't prepay: APPROVED is enough for them. + */ private canLoad(booking: Booking): boolean { - if (booking.status === 'PAID') return true; + if (booking.paymentStatus === 'PAID') return true; return booking.isGovernment && booking.status === 'APPROVED'; } @@ -117,6 +126,7 @@ export class BookingJourneyService { loadedAt: now, loadedByUserId: userId ?? null, }); + await this.wagonHistory.record(manager, this.cargoEvent(target, schedule, booking, 'LOADED', now, userId ?? null)); if (!booking.loadingStartedAt) { await manager .getRepository(Booking) @@ -188,7 +198,8 @@ export class BookingJourneyService { } if (!this.canLoad(booking)) { throw new BadRequestException( - `Booking must be paid before loading (currently ${booking.status})`, + `Booking must be paid before loading (payment status ${booking.paymentStatus ?? 'PENDING'}, ` + + `booking status ${booking.status})`, ); } await this.assertTrainAtYard(schedule, booking.originYardId, 'origin'); @@ -235,7 +246,12 @@ export class BookingJourneyService { if (booking.tradeDirection === 'DOMESTIC') { await this.autoPlaceOnFreedWagons(manager, schedule, booking); } - await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED'); + await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED', { + userId: userId ?? null, + at: now, + schedule, + booking, + }); // Keep the schedule↔booking link's tracking flag in sync — the dispatch // readiness warnings and workspace badges read loading_status, not loadedAt. await manager @@ -267,6 +283,20 @@ export class BookingJourneyService { }); }); + // What actually boarded, and what did not. A booking is routinely loaded in + // parts; the customer is told both halves, and the warehouse desk is told + // about the leftovers so somebody owns placing them. After the transaction: + // the lists are read back from the allocation statuses it just wrote. + void notifyLoadManifest( + this.dataSource, + this.notifications, + this.inbox, + bookingId, + scheduleId, + FREIGHT_PERMS.warehouseInventory.getNotification, + this.logger, + ); + // Customer tracking: cargo is on the train — loading milestones plus the // direction's "departed" handoff. Doc-trigger path no-ops non-customs // bookings (intercity) and already-completed codes. @@ -328,6 +358,7 @@ export class BookingJourneyService { unloadedAt: now, unloadedByUserId: userId ?? null, }); + await this.wagonHistory.record(null, this.cargoEvent(target, schedule, booking, 'DEPARTED', now, userId ?? null)); const remaining = allocations.filter( (a) => a.id !== target.id && a.status !== 'DEPARTED', @@ -385,7 +416,12 @@ export class BookingJourneyService { arrivedAt: now, arrivedByUserId: userId ?? null, } as never); - await this.setAllocationStatuses(manager, scheduleId, bookingId, 'DEPARTED'); + await this.setAllocationStatuses(manager, scheduleId, bookingId, 'DEPARTED', { + userId: userId ?? null, + at: now, + schedule, + booking, + }); await this.settleWagonsOnUnload(manager, schedule, booking, now, userId ?? null); // The facility took the cargo off the train — raise its GRN. Where the // facility also stores cargo (Indode), the event links the storage record @@ -464,6 +500,7 @@ export class BookingJourneyService { id: b.id, reference: b.reference, status: b.status, + paymentStatus: b.paymentStatus ?? null, tradeDirection: b.tradeDirection, isGovernment: b.isGovernment, customer: b.company?.name ?? 'Unknown customer', @@ -901,12 +938,61 @@ export class BookingJourneyService { scheduleId: string, bookingId: string, status: 'LOADED' | 'DEPARTED', + ctx?: { userId: string | null; at: Date; schedule: TrainSchedule; booking: Booking }, ): Promise { const allocations = await this.allocationsForBooking(manager, scheduleId, bookingId); if (!allocations.length) return; await manager .getRepository(WagonBookingAllocation) .update({ id: In(allocations.map((a) => a.id)) }, { status }); + if (!ctx) return; + // Per-wagon cargo history. Allocations already at (or past) the target + // status were logged by the per-wagon load/unload endpoint — skip them so + // the whole-booking completion never double-writes a wagon's row. + const pending = allocations.filter((a) => + status === 'LOADED' + ? a.status !== 'LOADED' && a.status !== 'DEPARTED' + : a.status !== 'DEPARTED', + ); + await this.wagonHistory.record( + manager, + pending + .map((a) => this.cargoEvent(a, ctx.schedule, ctx.booking, status, ctx.at, ctx.userId)) + .filter((e): e is WagonEventInput => e !== null), + ); + } + + /** CARGO_LOADED / CARGO_UNLOADED row for one allocation's physical wagon; null when the slot has no wagon pinned. */ + private cargoEvent( + alloc: WagonBookingAllocation & { trainSetWagon?: TrainSetWagon }, + schedule: TrainSchedule, + booking: Booking, + status: 'LOADED' | 'DEPARTED', + at: Date, + userId: string | null, + ): WagonEventInput | null { + const slot = alloc.trainSetWagon; + if (!slot?.physicalWagonId) return null; + const loaded = status === 'LOADED'; + return { + wagonId: slot.physicalWagonId, + wagonNumber: slot.physicalWagon?.wagonNumber ?? null, + type: loaded ? Freight.WagonEventType.CargoLoaded : Freight.WagonEventType.CargoUnloaded, + occurredAt: at, + actorUserId: userId, + toYardId: loaded + ? (slot.boardYardId ?? schedule.originStationId ?? null) + : (booking.destinationYardId ?? slot.alightYardId ?? schedule.destinationStationId ?? null), + trainScheduleId: schedule.id, + trainId: schedule.trainSet?.trainId ?? null, + bookingId: booking.id, + toValue: booking.reference ?? null, + metadata: { + allocationId: alloc.id, + loadType: alloc.loadType ?? null, + weightTons: Number(alloc.allocatedWeightTons ?? 0), + }, + }; } private async allocationsForBooking( @@ -994,6 +1080,20 @@ export class BookingJourneyService { ? Freight.WagonStatus.Assigned : Freight.WagonStatus.Available, }); + await this.wagonHistory.record(manager, { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: Freight.WagonEventType.ReleasedAtUnload, + occurredAt: now, + actorUserId: userId, + fromYardId: boardYardId ?? null, + toYardId: booking.destinationYardId ?? null, + trainScheduleId: schedule.id, + trainId: wagon.trainId ?? null, + bookingId: booking.id, + toValue: wagon.trainId ? Freight.WagonStatus.Assigned : Freight.WagonStatus.Available, + metadata: { slotId: slot.id }, + }); } } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.spec.ts new file mode 100644 index 000000000..524eeddc7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.spec.ts @@ -0,0 +1,76 @@ +import { BookingNotifierService } from './booking-notifier.service'; + +/** + * Message wording for the schedule-related customer notices: every one must + * quote the SCHEDULE's train + voyage numbers, and reschedules must carry the + * staff-entered reason instead of a hard-coded "for maintenance". + */ +describe('BookingNotifierService messages', () => { + const schedule = { trainNumber: '8001', voyageNumber: 'V-117' }; + const booking = { id: 'b1', reference: 'BK-2026-000928', companyId: 'c1' } as never; + const departure = new Date('2026-09-01T05:00:00.000Z'); + + let sent: string[]; + let inbox: string[]; + let service: BookingNotifierService; + + beforeEach(() => { + sent = []; + inbox = []; + const notifications = { + directSend: jest.fn(async (_m: string, _to: string, msg: string) => { + sent.push(msg); + }), + }; + const inboxSvc = { + notify: jest.fn(async (input: { body: string }) => { + inbox.push(input.body); + }), + }; + const trainSchedules = { + findByIdWithStations: jest.fn(async () => ({ ...schedule, reference: 'S-2026-00012' })), + }; + // Company contact lookup goes through raw SQL; return one phone + email. + const dataSource = { + query: jest.fn(async () => [{ phone: '+251900000000', email: 'ops@example.com' }]), + }; + service = new BookingNotifierService( + notifications as never, + inboxSvc as never, + trainSchedules as never, + dataSource as never, + ); + }); + + const flush = () => new Promise((r) => setImmediate(r)); + + it('maintenance reschedule quotes train, voyage and the staff reason', async () => { + service.maintenanceMoved(booking, departure, schedule, 'Locomotive maintenance.'); + await flush(); + expect(inbox[0]).toBe( + 'Train 8001 (voyage V-117) for booking BK-2026-000928 was rescheduled — reason: Locomotive maintenance. ' + + 'New departure date: 01/09/2026.', + ); + }); + + it('maintenance reschedule falls back to "for maintenance" without a reason', async () => { + service.maintenanceMoved(booking, departure, schedule, ' '); + await flush(); + expect(inbox[0]).toContain('was rescheduled for maintenance. New departure date'); + }); + + it('plain reschedule carries the reason and the run label', async () => { + service.rescheduled(booking, departure, schedule, 'Crew change'); + await flush(); + expect(inbox[0]).toBe( + 'Booking BK-2026-000928 on train 8001 (voyage V-117) has been rescheduled — reason: Crew change. ' + + 'New departure date: 01/09/2026.', + ); + }); + + it('resolves the run label from a schedule id when only the id is known', async () => { + service.scheduleCancelled(booking, 'sched-1'); + await flush(); + expect(inbox[0]).toMatch(/^Train 8001 \(voyage V-117\) for booking BK-2026-000928 has been cancelled/); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index 4600f7f38..fd42fdd7b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -14,8 +14,21 @@ import { NotificationInboxService } from '../notification-inbox/notification-inb import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util'; import { resolveShippingLineNotifyTarget } from '../notifications/resolve-shipping-line-contact.util'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; +import { trainRunLabel, type TrainRunSource } from '../train-schedules/train-run-label.util'; import { BATCH_TIMEZONE } from './booking-batch.constants'; +const capitalize = (text: string): string => text.charAt(0).toUpperCase() + text.slice(1); + +/** + * " — reason: Locomotive maintenance" for the staff-entered reschedule reason, + * or '' when none was given. Trailing punctuation is trimmed so the sentence's + * own full stop follows cleanly. + */ +const reasonClause = (reason?: string | null): string => { + const text = reason?.trim().replace(/[.\s]+$/, ''); + return text ? ` — reason: ${text}` : ''; +}; + @Injectable() export class BookingNotifierService { private readonly logger = new Logger(BookingNotifierService.name); @@ -30,8 +43,9 @@ export class BookingNotifierService { /** * Human-readable description of a train schedule for customer messages: - * reference (or train number) + route + departure date. Never leaks a UUID — - * falls back to a generic phrase when the schedule can't be loaded. + * train number + voyage number (both the SCHEDULE's own — see trainRunLabel), + * then reference, route and departure date. Never leaks a UUID — falls back + * to a generic phrase when the schedule can't be loaded. */ private async scheduleLabel(scheduleId?: string | null): Promise { const fallback = 'your selected train'; @@ -39,8 +53,10 @@ export class BookingNotifierService { try { const s = await this.trainSchedules.findByIdWithStations(scheduleId); if (!s) return fallback; - // Customers know the train by its operating number (8001), not the - // schedule reference — lead with it and keep S-… as the secondary id. + // Customers know the departure by its train number (8001) and voyage + // number, not the schedule reference — lead with those and keep S-… as + // the secondary id. + const run = trainRunLabel(s); const parts = [ s.reference, s.originStation?.label && s.destinationStation?.label @@ -59,9 +75,9 @@ export class BookingNotifierService { hour12: false, })} EAT` : ''; - const number = s.trainNumber ?? s.reference ?? null; - return number - ? `train ${number}${number === s.reference ? '' : detail}${departure}` + if (run) return `${run}${detail}${departure}`; + return s.reference + ? `train ${s.reference}${departure}` : `${fallback}${detail}${departure}`; } catch (err) { this.logger.warn( @@ -71,6 +87,51 @@ export class BookingNotifierService { } } + /** + * "train 8001 (voyage V-117)" for the departure a message is about, or null + * when nothing is known. Accepts the schedule row itself (preferred — callers + * that have just cancelled or detached the booking still hold it) or its id, + * falling back to the booking's own train_schedule_id. Never throws: a label + * lookup must not stop a notification going out. + */ + private async trainRun( + b: Booking, + schedule?: TrainRunSource | string | null, + ): Promise { + if (schedule && typeof schedule !== 'string') return trainRunLabel(schedule); + const scheduleId = schedule ?? b.trainScheduleId ?? null; + if (!scheduleId) return null; + try { + const s = await this.trainSchedules.findByIdWithStations(scheduleId); + return trainRunLabel(s); + } catch (err) { + this.logger.warn(`trainRun(${scheduleId}) failed: ${(err as Error).message}`); + return null; + } + } + + /** + * Resolve the run label, then build and send the SMS/email + in-app item. + * Fire-and-forget like every notifier method; `build` receives the label + * (null when unknown) and returns the message text. + */ + private withRun( + b: Booking, + schedule: TrainRunSource | string | null | undefined, + logLabel: string, + title: string, + build: (run: string | null) => string, + opts: { contact?: boolean; inApp?: Partial } = {}, + ): void { + void (async () => { + const msg = build(await this.trainRun(b, schedule)); + if (opts.contact !== false) await this.notifyContact(b, msg, logLabel); + this.inApp(b, title, msg, opts.inApp); + })().catch((err) => + this.logger.warn(`${logLabel} notification failed for ${this.ref(b)}: ${(err as Error).message}`), + ); + } + private ref(b: Booking): string { return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`; } @@ -162,21 +223,30 @@ export class BookingNotifierService { } /** Train carrying the booking departed — dispatched origin → destination. */ - dispatched(b: Booking, origin: string | null, destination: string | null): void { - const msg = + dispatched( + b: Booking, + origin: string | null, + destination: string | null, + schedule?: TrainRunSource | string | null, + ): void { + this.withRun(b, schedule, 'DISPATCHED', 'Shipment dispatched', (run) => `Your booking ${b.reference ?? b.id} has been dispatched` + - `${origin || destination ? ` from ${origin ?? '?'} to ${destination ?? '?'}` : ''}.`; - void this.notifyContact(b, msg, 'DISPATCHED'); - this.inApp(b, 'Shipment dispatched', msg); + `${origin || destination ? ` from ${origin ?? '?'} to ${destination ?? '?'}` : ''}` + + `${run ? ` on ${run}` : ''}.`, + ); } /** Train carrying the booking arrived at destination. */ - arrived(b: Booking, origin: string | null, destination: string | null): void { - const msg = - `Your booking ${b.reference ?? b.id} has arrived` + - `${destination ? ` at ${destination}` : ''}${origin ? ` (from ${origin})` : ''}.`; - void this.notifyContact(b, msg, 'ARRIVED'); - this.inApp(b, 'Shipment arrived', msg); + arrived( + b: Booking, + origin: string | null, + destination: string | null, + schedule?: TrainRunSource | string | null, + ): void { + this.withRun(b, schedule, 'ARRIVED', 'Shipment arrived', (run) => + `Your booking ${b.reference ?? b.id}${run ? ` on ${run}` : ''} has arrived` + + `${destination ? ` at ${destination}` : ''}${origin ? ` (from ${origin})` : ''}.`, + ); } async payNow(b: Booking, deadline: Date): Promise { @@ -318,21 +388,28 @@ export class BookingNotifierService { ); } - displaced(b: Booking): void { - const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`; - void this.notifyContact(b, msg, 'DISPLACED'); - this.inApp(b, 'Booking displaced', msg); + displaced(b: Booking, schedule?: TrainRunSource | string | null): void { + this.withRun(b, schedule, 'DISPLACED', 'Booking displaced', (run) => + `Booking ${b.reference ?? b.id} was displaced${run ? ` from ${run}` : ''} by a government booking. ` + + `Move to another schedule or cancel.`, + ); } /** * Staff rescheduled the train carrying this booking to a new departure date. * The booking stays on the train — only the date moved. */ - rescheduled(b: Booking, newDeparture: Date): void { + rescheduled( + b: Booking, + newDeparture: Date, + schedule?: TrainRunSource | string | null, + reason?: string | null, + ): void { const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE }); - const msg = `Booking ${b.reference ?? b.id} has been rescheduled. New departure date: ${when}.`; - void this.notifyContact(b, msg, 'RESCHEDULED'); - this.inApp(b, 'Booking rescheduled', msg); + this.withRun(b, schedule, 'RESCHEDULED', 'Booking rescheduled', (run) => + `Booking ${b.reference ?? b.id}${run ? ` on ${run}` : ''} has been rescheduled` + + `${reasonClause(reason)}. New departure date: ${when}.`, + ); } /** @@ -340,49 +417,75 @@ export class BookingNotifierService { * the customer's original choice. In-app only — staff drove the change and * the allocation itself already notifies through the secured path. */ - allocatedOtherDay(b: Booking, newDeparture: Date): void { + allocatedOtherDay( + b: Booking, + newDeparture: Date, + schedule?: TrainRunSource | string | null, + ): void { const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE }); - const msg = - `Booking ${b.reference ?? b.id} has been allocated to a train on a different date. ` + - `New departure date: ${when}.`; - this.inApp(b, 'Booking allocated to another date', msg); + this.withRun( + b, + schedule, + 'ALLOCATED OTHER DAY', + 'Booking allocated to another date', + (run) => + `Booking ${b.reference ?? b.id} has been allocated to ${run ?? 'a train'} on a different date. ` + + `New departure date: ${when}.`, + { contact: false }, + ); } /** * Booking was removed from its train during a staff reschedule (not a government * pre-empt). It returns to eligible — the customer must rebook or reschedule. */ - removedFromTrain(b: Booking): void { - const msg = - `Booking ${b.reference ?? b.id} has been removed from its train during rescheduling. ` + - `Please rebook or select a new schedule from the portal.`; - void this.notifyContact(b, msg, 'REMOVED FROM TRAIN'); - this.inApp(b, 'Removed from train', msg); + removedFromTrain(b: Booking, schedule?: TrainRunSource | string | null): void { + this.withRun(b, schedule, 'REMOVED FROM TRAIN', 'Removed from train', (run) => + `Booking ${b.reference ?? b.id} has been removed from ${run ?? 'its train'} during rescheduling. ` + + `Please rebook or select a new schedule from the portal.`, + ); } /** * The train carrying this booking was cancelled. The booking is detached and * returns to the eligible pool — the customer must rebook or pick a new schedule. */ - scheduleCancelled(b: Booking): void { - const msg = - `The train for booking ${b.reference ?? b.id} has been cancelled. ` + - `Your booking is not lost — please rebook or select a new schedule from the portal.`; - void this.notifyContact(b, msg, 'TRAIN CANCELLED'); + scheduleCancelled(b: Booking, schedule?: TrainRunSource | string | null): void { // HIGH: a cancelled train invalidates the customer's plans — must reach SMS/email. - this.inApp(b, 'Train cancelled', msg, { priority: NotificationPriority.HIGH }); + this.withRun( + b, + schedule, + 'TRAIN CANCELLED', + 'Train cancelled', + (run) => + `${run ? capitalize(run) : 'The train'} for booking ${b.reference ?? b.id} has been cancelled. ` + + `Your booking is not lost — please rebook or select a new schedule from the portal.`, + { inApp: { priority: NotificationPriority.HIGH } }, + ); } /** - * The train carrying this booking was moved for maintenance to a new departure - * date. The booking stays on the train — only the date moved. + * The train carrying this booking was moved (maintenance reschedule) to a new + * departure date. The booking stays on the train — only the date moved. The + * staff-entered reason is what the customer reads; "for maintenance" is only + * the fallback when none was typed. */ - maintenanceMoved(b: Booking, newDeparture: Date): void { + maintenanceMoved( + b: Booking, + newDeparture: Date, + schedule?: TrainRunSource | string | null, + reason?: string | null, + ): void { const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE }); - const msg = - `The train for booking ${b.reference ?? b.id} was rescheduled for maintenance. ` + - `New departure date: ${when}.`; - void this.notifyContact(b, msg, 'MAINTENANCE RESCHEDULE'); - this.inApp(b, 'Train maintenance reschedule', msg); + const why = reason?.trim() ? reasonClause(reason) : ' for maintenance'; + this.withRun( + b, + schedule, + 'MAINTENANCE RESCHEDULE', + 'Train rescheduled', + (run) => + `${run ? capitalize(run) : 'The train'} for booking ${b.reference ?? b.id} was rescheduled${why}. ` + + `New departure date: ${when}.`, + ); } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 47d0a0d21..49a730e68 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -11,6 +11,7 @@ import { import { Booking } from '../bookings/entities/booking.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; +import { trainRunLabel } from '../train-schedules/train-run-label.util'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { @@ -671,8 +672,11 @@ export class BookingWindowService implements OnModuleInit { const depart = schedule.scheduledDepartureDate.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE, }); + // Name the departure by the schedule's train + voyage numbers (never the + // built train's name) so customers can match it to yard/customs paperwork. + const run = trainRunLabel(schedule); const msg = - `Booking is now open for the train departing ${depart}. ` + + `Booking is now open for ${run ?? 'the train'} departing ${depart}. ` + `Book your shipment from the portal home page before ${closes} EAT.`; const seenPhone = new Set(); @@ -797,8 +801,9 @@ export class BookingWindowService implements OnModuleInit { const depart = schedule.scheduledDepartureDate.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE, }); + const run = trainRunLabel(schedule, { capitalize: true }); const msgFor = (corridors: string[]) => - `A train is scheduled on your intercity corridor ${corridors.join(', ')}, ` + + `${run ?? 'A train'} is scheduled on your intercity corridor ${corridors.join(', ')}, ` + `departing ${depart}. EDR will confirm once your cargo is placed on a train.`; // One inbox item per booking (its `data` is the once-per-booking marker diff --git a/apps/edr-freight-api/src/modules/train-scheduling/checkpoint-leave-behind.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/checkpoint-leave-behind.spec.ts new file mode 100644 index 000000000..c9ee445ca --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/checkpoint-leave-behind.spec.ts @@ -0,0 +1,268 @@ +import { BadRequestException } from "@nestjs/common"; + +import { TrainSchedulingService } from "./services/train-scheduling.service"; + +/** + * Mid-corridor leave-behind. Logging a pass at station N means the train has + * LEFT station N-1, so cargo that boarded back there has had its last chance + * to load: anything the operator did not tick rides no further and is + * unassigned back to the booking pool. + * + * Dispatch already does this for the origin yard; these cover the log-pass + * twin, plus the structured payload the UI needs to offer the + * EDR-fault / customer-fault cut on a part-loaded booking. + */ +describe("recordCheckpoint — mid-corridor leave-behind", () => { + const STATIONS = [ + { sequenceNo: 0, yardId: "yard-a", label: "Yard A" }, + { sequenceNo: 1, yardId: "yard-b", label: "Yard B" }, + { sequenceNo: 2, yardId: "yard-c", label: "Yard C" }, + ]; + + /** + * Exercises the leave-behind block in isolation — the surrounding + * recordCheckpoint does heavy graph/transaction work irrelevant here. + */ + const runLeaveBehind = async ( + dto: { sequenceNo: number; loadedBookingIds?: string[] }, + candidatesByYard: Record, + ) => { + const unassigned: Array<{ scheduleId: string; bookingId: string }> = []; + const svc = Object.create(TrainSchedulingService.prototype) as { + unloadedBoarderIdsAtYard( + scheduleId: string, + yardId: string, + ): Promise; + unassignBooking( + scheduleId: string, + bookingId: string, + userId?: string, + ): Promise; + }; + svc.unloadedBoarderIdsAtYard = async ( + _scheduleId: string, + yardId: string, + ) => candidatesByYard[yardId] ?? []; + svc.unassignBooking = async (scheduleId: string, bookingId: string) => { + unassigned.push({ scheduleId, bookingId }); + }; + + // Mirrors the block inside recordCheckpoint. + if (dto.loadedBookingIds && dto.sequenceNo > 0) { + const departedYardId = STATIONS.find( + (s) => s.sequenceNo === dto.sequenceNo - 1, + )?.yardId; + if (departedYardId) { + const keep = new Set(dto.loadedBookingIds); + const candidates = await svc.unloadedBoarderIdsAtYard( + "sched-1", + departedYardId, + ); + for (const bookingId of candidates.filter((id) => !keep.has(id))) { + await svc.unassignBooking("sched-1", bookingId, undefined); + } + } + } + return unassigned.map((u) => u.bookingId); + }; + + it("drops the unticked boarders of the yard the train just left", async () => { + // b4 and b5 boarded at Yard B; only b5 was loaded. Logging Yard C means + // the train has left B, so b4 is stranded and comes off the train. + const dropped = await runLeaveBehind( + { sequenceNo: 2, loadedBookingIds: ["b5"] }, + { "yard-b": ["b4", "b5"] }, + ); + expect(dropped).toEqual(["b4"]); + }); + + it("scopes the drop to the DEPARTED yard, never the one being logged", async () => { + // Cargo boarding at Yard C is not due until the train is there — logging + // the pass at C must not shed it. + const dropped = await runLeaveBehind( + { sequenceNo: 2, loadedBookingIds: [] }, + { "yard-b": [], "yard-c": ["b6", "b7"] }, + ); + expect(dropped).toEqual([]); + }); + + it("leaves nobody behind when the client omits the list", async () => { + // Older clients send no list — the historic behavior is that everyone rides. + const dropped = await runLeaveBehind( + { sequenceNo: 2 }, + { "yard-b": ["b4"] }, + ); + expect(dropped).toEqual([]); + }); + + it("does not shed at the origin — that is dispatch's decision", async () => { + const dropped = await runLeaveBehind( + { sequenceNo: 0, loadedBookingIds: [] }, + { "yard-a": ["b1", "b3"] }, + ); + expect(dropped).toEqual([]); + }); + + it("keeps every ticked booking on the train", async () => { + const dropped = await runLeaveBehind( + { sequenceNo: 2, loadedBookingIds: ["b4", "b5"] }, + { "yard-b": ["b4", "b5"] }, + ); + expect(dropped).toEqual([]); + }); +}); + +describe("assertNoPartiallyLoadedBookings — structured payload", () => { + const makeService = ( + rows: Array<{ + bookingId: string; + reference: string; + loaded: string; + total: string; + unloadedAllocationIds: string[]; + }>, + ) => { + const svc = Object.create(TrainSchedulingService.prototype) as { + dataSource: { + query: (sql: string, params: unknown[]) => Promise; + }; + assertNoPartiallyLoadedBookings( + schedule: unknown, + boardingYardId: string, + context: { action: string; yardLabel?: string }, + ): Promise; + }; + svc.dataSource = { query: async () => rows }; + return svc; + }; + const schedule = { + id: "sched-1", + trainSetId: "set-1", + originStationId: "yard-a", + }; + + it("carries the never-loaded allocation ids the fault-cut modal needs", async () => { + const svc = makeService([ + { + bookingId: "b4", + reference: "BK-2026-000853", + loaded: "1", + total: "4", + unloadedAllocationIds: ["w2", "w3", "w4"], + }, + ]); + + const err = await svc + .assertNoPartiallyLoadedBookings(schedule, "yard-b", { + action: "record this checkpoint", + yardLabel: "Yard B", + }) + .catch((e: unknown) => e); + + expect(err).toBeInstanceOf(BadRequestException); + const body = (err as BadRequestException).getResponse() as { + code: string; + message: string; + partiallyLoaded: { + yardLabel: string | null; + bookings: Array<{ + bookingId: string; + loadedWagons: number; + totalWagons: number; + unloadedAllocationIds: string[]; + }>; + }; + }; + + expect(body.code).toBe("PARTIALLY_LOADED_BOOKINGS"); + expect(body.partiallyLoaded.yardLabel).toBe("Yard B"); + expect(body.partiallyLoaded.bookings).toEqual([ + { + bookingId: "b4", + reference: "BK-2026-000853", + loadedWagons: 1, + totalWagons: 4, + unloadedAllocationIds: ["w2", "w3", "w4"], + }, + ]); + // The prose message survives for logs and older clients. + expect(body.message).toContain("BK-2026-000853 (1/4 wagons loaded)"); + }); + + it("stays silent when nothing at the yard is half-loaded", async () => { + const svc = makeService([]); + await expect( + svc.assertNoPartiallyLoadedBookings(schedule, "yard-b", { + action: "dispatch", + }), + ).resolves.toBeUndefined(); + }); +}); + +/** + * Dispatch's origin auto-load. This UPDATE is the reason an unticked booking + * could still end up marked loaded: it stamps every PAID origin boarder, so + * without the confirmed-list guard a booking left attached (or one the + * unassign predicate cannot shed) rides as if its cargo were aboard. + */ +describe('dispatchSchedule — origin auto-load respects the confirmed list', () => { + /** Mirrors the `($4::uuid[] IS NULL OR b.id = ANY($4::uuid[]))` guard. */ + const wouldAutoLoad = (bookingId: string, confirmed: string[] | undefined) => + confirmed === undefined || confirmed.includes(bookingId); + + it('stamps only the ticked bookings', () => { + expect(wouldAutoLoad('b2', ['b2'])).toBe(true); + expect(wouldAutoLoad('b1', ['b2'])).toBe(false); + }); + + it('stamps nobody when the operator unticks everyone', () => { + expect(wouldAutoLoad('b1', [])).toBe(false); + }); + + it('keeps the historic auto-load for clients that send no list', () => { + expect(wouldAutoLoad('b1', undefined)).toBe(true); + expect(wouldAutoLoad('b2', undefined)).toBe(true); + }); +}); + +/** + * Empty wagons must travel with their train. + * + * The checkpoint position fix moves wagons by `current_train_schedule_id`, but + * dispatch used to bind only the PINNED slots (the ones carrying cargo). A + * built train rolls with its whole consist, so every empty wagon coupled to it + * was left unbound — and stayed recorded at the origin yard while the train it + * is hooked to travelled the corridor. + */ +describe('dispatchSchedule — the whole consist travels, not just loaded slots', () => { + /** Mirrors dispatch's binding set: pinned slots ∪ built-train consist. */ + const boundAtDispatch = ( + pinnedSlotWagonIds: Array, + builtTrainWagonIds: string[], + ) => [ + ...new Set([ + ...pinnedSlotWagonIds.filter((id): id is string => Boolean(id)), + ...builtTrainWagonIds, + ]), + ]; + + it('binds the empty wagons coupled to the built train', () => { + // The real shape of the reported schedule: 3 slots carry cargo, 45 empties + // ride along. All 48 must move when a checkpoint is logged. + const pinned = ['w1', 'w2', 'w3']; + const consist = ['w1', 'w2', 'w3', 'e1', 'e2', 'e3']; + const bound = boundAtDispatch(pinned, consist); + expect(bound).toEqual(['w1', 'w2', 'w3', 'e1', 'e2', 'e3']); + expect(bound).toContain('e1'); + }); + + it('never double-binds a wagon that is both pinned and on the train', () => { + const bound = boundAtDispatch(['w1', 'w1'], ['w1']); + expect(bound).toEqual(['w1']); + }); + + it('still binds pinned slots when there is no built train', () => { + // A set-only schedule (no Train row) has no consist to add. + expect(boundAtDispatch(['w1', null, 'w2'], [])).toEqual(['w1', 'w2']); + }); +}); 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 fbf555444..4fc921ccb 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 @@ -895,6 +895,28 @@ export class TrainSchedulingController { return res.send(buffer); } + @Get("schedules/:id/marshalling/stops") + @TrainSchedulingView() + @ApiOperation({ summary: "Corridor stops with a logged consist change, in order (Marshalling 2, 3, 4…)" }) + marshallingStops(@Param("id", ParseUUIDPipe) id: string) { + return this.trainSchedulingService.marshallingStops(id); + } + + @Get("schedules/:id/marshalling/document/:stopIndex") + @TrainSchedulingView() + @ApiOperation({ summary: "Download the numbered marshalling PDF for one corridor stop" }) + async marshallingDocumentAt( + @Param("id", ParseUUIDPipe) id: string, + @Param("stopIndex", ParseIntPipe) stopIndex: number, + @Res() res: Response, + ) { + const { filename, buffer } = await this.trainSchedulingService.marshallingDocumentAt(id, stopIndex); + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `inline; filename="${filename}"`); + res.setHeader("Content-Length", buffer.length); + return res.send(buffer); + } + // ---- batch / booking-window staff actions ---- @Post("schedules/:id/run-batch") diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts index 18904c032..44d0615af 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts @@ -6,10 +6,13 @@ import { IsBoolean, IsDateString, IsInt, + IsNotEmpty, IsNumber, IsOptional, + IsString, IsUUID, Max, + MaxLength, Min, ValidateNested, } from 'class-validator'; @@ -119,6 +122,19 @@ export class CreateContainerTrainScheduleDto { @IsDateString() scheduleDate!: string; + @ApiProperty({ + example: 'V-2026-0620', + maxLength: 20, + description: + 'Voyage (sailing) number for this departure — the run identifier yards and ' + + 'customs quote. Required at creation; the UI pre-fills it with the built ' + + "train's direction-matched run number, but staff may override it.", + }) + @IsString() + @IsNotEmpty({ message: 'A voyage number is required' }) + @MaxLength(20) + voyageNumber!: string; + @ApiPropertyOptional({ format: 'uuid', description: 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 06c363bc4..d96a1db4a 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,5 +1,5 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { TrainCheckpointKind } from '@edr/types'; +import { ApiProperty } from "@nestjs/swagger"; +import { TrainCheckpointKind } from "@edr/types"; import { IsArray, IsEnum, @@ -10,10 +10,12 @@ import { IsUUID, MaxLength, Min, -} from 'class-validator'; +} from "class-validator"; export class RecordCheckpointDto { - @ApiProperty({ description: 'Station position along the route (0 = origin).' }) + @ApiProperty({ + description: "Station position along the route (0 = origin).", + }) @IsInt() @Min(0) sequenceNo!: number; @@ -31,7 +33,7 @@ export class RecordCheckpointDto { @ApiProperty({ required: false, description: - 'ISO timestamp; defaults to now. Past allowed, future rejected, must be in corridor order.', + "ISO timestamp; defaults to now. Past allowed, future rejected, must be in corridor order.", }) @IsOptional() @IsISO8601() @@ -42,7 +44,10 @@ export class RecordCheckpointDto { * loading and unloading time. All optional: a stop logged without them still * records its staying time. */ - @ApiProperty({ required: false, description: 'ISO timestamp; unloading start.' }) + @ApiProperty({ + required: false, + description: "ISO timestamp; unloading start.", + }) @IsOptional() @IsISO8601() unloadingStartedAt?: string; @@ -62,6 +67,24 @@ export class RecordCheckpointDto { @IsISO8601() loadingCompletedAt?: string; + /** + * Mid-corridor leave-behind, the log-pass twin of DispatchScheduleDto's field. + * Recording THIS station means the train left the previous one, so the + * bookings that boarded back there have had their last chance to load. When + * present, only these ride on; every other unloaded boarder of the departed + * yard is deallocated from its wagon and returned to the booking pool. + * Absent (older clients) = nobody is left behind, the historic behavior. + */ + @ApiProperty({ + required: false, + description: + "Bookings from the yard just departed confirmed loaded; the rest are unassigned back to the pool. Omit to leave nobody behind.", + }) + @IsOptional() + @IsArray() + @IsUUID("4", { each: true }) + loadedBookingIds?: string[]; + @ApiProperty({ required: false }) @IsOptional() @IsString() @@ -73,7 +96,8 @@ export class RecordCheckpointDto { export class UpdateCheckpointDto { @ApiProperty({ required: false, - description: 'ISO timestamp. Past allowed, future rejected, must be in corridor order.', + description: + "ISO timestamp. Past allowed, future rejected, must be in corridor order.", }) @IsOptional() @IsISO8601() @@ -110,7 +134,8 @@ export class UpdateCheckpointDto { export class DispatchScheduleDto { @ApiProperty({ required: false, - description: 'Actual departure time; defaults to now. Past allowed, future rejected.', + description: + "Actual departure time; defaults to now. Past allowed, future rejected.", }) @IsOptional() @IsISO8601() @@ -125,10 +150,10 @@ export class DispatchScheduleDto { @ApiProperty({ required: false, description: - 'Origin-yard bookings confirmed loaded; the rest are unassigned back to the pool. Omit to auto-load all.', + "Origin-yard bookings confirmed loaded; the rest are unassigned back to the pool. Omit to auto-load all.", }) @IsOptional() @IsArray() - @IsUUID('4', { each: true }) + @IsUUID("4", { each: true }) loadedBookingIds?: string[]; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts index 4e2724f49..121dd5ed6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -7,6 +7,7 @@ import { import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; +import { bookingTonsSql } from '../bookings/booking-tons.sql'; import { Booking } from '../bookings/entities/booking.entity'; import { RouteMilestone } from '../routes/entities/route-milestone.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; @@ -58,7 +59,7 @@ export class IntercityService { b.reference AS "reference", b.status AS "status", b.freight_type AS "freightType", - b.cargo_total_weight_vgm AS "weightTons", + ${bookingTonsSql('b')} AS "weightTons", b.loaded_at AS "loadedAt", b.arrived_at AS "arrivedAt", company.name AS "customer", @@ -438,6 +439,7 @@ export class IntercityService { id: booking.id, reference: booking.reference, status: booking.status, + paymentStatus: booking.paymentStatus ?? null, freightType: booking.freightType, isGovernment: booking.isGovernment, customer: booking.company?.name ?? 'Unknown customer', diff --git a/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts index 85b6d0878..e95cb4fe8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts @@ -333,7 +333,9 @@ export class RemainderPlacementService { return deferred.map((u) => ({ containerNumber: u.containerNumber, - sealNumber: u.sealNumber ?? undefined, + // Deferred units predate the seal requirement; the booking service + // normalizes the blank back to null rather than rejecting the re-book. + sealNumber: u.sealNumber ?? '', vgmTons: Number(u.vgmTons), isHazardous: u.isHazardous, isReefer: u.isReefer, 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 57bbb2c58..f61593442 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 @@ -508,6 +508,7 @@ describe('TrainSchedulingService', () => { const result = await service.createContainerTrainSchedule({ routeId: 'route-1', scheduleDate: futureDeparture, + voyageNumber: 'V-TEST-1', locomotiveIds: ['loc-1', 'loc-2'], }); @@ -610,6 +611,7 @@ describe('TrainSchedulingService', () => { service.createContainerTrainSchedule({ routeId: 'route-1', scheduleDate: '2026-06-20T08:00:00.000Z', + voyageNumber: 'V-TEST-2', locomotiveIds: ['loc-1', 'loc-2'], }), ).rejects.toBeInstanceOf(ConflictException); @@ -1062,6 +1064,7 @@ describe('TrainSchedulingService', () => { const makeWagon = (sequenceNo: number, wagonNumber: string, allocations: unknown[]) => ({ sequenceNo, wagonNumber, + physicalWagonId: `wagon-id-${wagonNumber}`, physicalWagon: { wagonNumber }, wagonType: { code: 'NW5', name: 'Flat Wagon', tareWeightTons: 22 }, lengthMeters: 14, @@ -1134,6 +1137,226 @@ describe('TrainSchedulingService', () => { expect(html).toContain('2 (1 empty)'); }); + it('drops a leg slot entirely from the import document — not part of the departing consist', () => { + 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); + + // The leg slot (W-ICY, boards later at Dire Dawa) gets no row at all — + // it isn't on the departing consist. Only W-IMP appears. + expect(html).not.toContain('W-ICY'); + expect(html).not.toContain('ICY-001'); + expect(html).toContain('W-IMP'); + expect(html).toContain('Wagons1'); + expect(html).toContain('Total containers1'); + }); + + it('drops a slot with no physical wagon pinned from the import document too', () => { + 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, + // No physical wagon pinned (fleet shortfall, or a REAL cut nulled + // it out) — nothing physical to marshal, even though the slot + // still carries a LOADED allocation. + wagonNumber: null, + boardYard: null, + alightYard: null, + allocations: [ + { + ...loadedAllocation, + containerItems: [{ containerNumber: 'GHOST-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).not.toContain('GHOST-001'); + expect(html).toContain('W-IMP'); + expect(html).toContain('Wagons1'); + expect(html).toContain('Total containers1'); + }); + + it('drops a leg slot entirely from the export document — not part of the departing consist', () => { + 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']]), + }); + + // The leg slot (W-LEG, boards later at Dire Dawa) gets no row at all. + expect(html).not.toContain('W-LEG'); + expect(html).toContain('Wagons1'); + expect(html).toContain('Total containers1'); + }); + + it('drops a whole-route slot with no physical wagon pinned from the export document too', () => { + const sizedAllocation = { + ...loadedAllocation, + containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }], + }; + const ghost = { ...makeWagon(2, 'W-GHOST', [sizedAllocation]), id: 'slot-ghost', physicalWagonId: null }; + const schedule = { + id: 'schedule-1', + trainNumber: '8302', + direction: 'EXPORT', + trainSet: { wagons: [{ ...makeWagon(1, 'W-001', [sizedAllocation]), id: 'slot-1' }, ghost] }, + scheduleBookings: [], + }; + + const html = (service as never as { + buildExportLoadListHtml: (s: unknown, o?: unknown) => string; + }).buildExportLoadListHtml(schedule, {}); + + expect(html).not.toContain('W-GHOST'); + expect(html).toContain('Wagons1'); + expect(html).toContain('Total containers1'); + }); + + it('prints the consist-changes table for this stop, and omits it 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: [ + { wagonNumber: 'W-1002', event: 'Coupled', containerNumbers: 'EMPTY WAGON' }, + { wagonNumber: 'W-1005', event: 'Coupled', containerNumbers: 'CONT-004, CONT-005' }, + { wagonNumber: 'W-0501 → W-1003', event: 'Switched', containerNumbers: 'CONT-011' }, + ], + }); + expect(withChanges).toContain('Consist Changed At This Stop'); + expect(withChanges).toContain('W-1002'); + expect(withChanges).toContain('Coupled'); + expect(withChanges).toContain('EMPTY WAGON'); + expect(withChanges).toContain('CONT-004, CONT-005'); + expect(withChanges).toContain('W-0501 → W-1003'); + expect(withChanges).toContain('Switched'); + + const withoutChanges = build(schedule, {}); + expect(withoutChanges).not.toContain('Consist Changed At This Stop'); + }); + + it('shows per-row Departure/Arrival Station — schedule endpoints for a whole-route wagon, its own board/alight yard for a leg slot', () => { + const wholeRoute = { ...makeWagon(1, 'W-001', [loadedAllocation]), id: 'slot-1' }; + const legSlot = { + ...makeWagon(2, 'W-LEG', [loadedAllocation]), + id: 'slot-leg', + boardYardId: 'yard-dire', + alightYardId: 'yard-adama', + }; + const schedule = { + id: 'schedule-1', + trainNumber: '8302', + direction: 'EXPORT', + originStation: { label: 'DCT/SGTD' }, + destinationStation: { label: 'GMP (Gelan Multipurpose Port)' }, + trainSet: { wagons: [wholeRoute, legSlot] }, + scheduleBookings: [], + }; + const build = (service as never as { + buildExportLoadListHtml: (s: unknown, o?: unknown) => string; + }).buildExportLoadListHtml.bind(service); + + const html = build(schedule, { + yardLabelById: new Map([ + ['yard-dire', 'Dire Dawa Port'], + ['yard-adama', 'Adama'], + ]), + }); + + expect(html).toContain('Departure Station'); + expect(html).toContain('Arrival Station'); + // Whole-route wagon: schedule's own endpoints. + expect(html).toContain('DCT/SGTD'); + expect(html).toContain('GMP (Gelan Multipurpose Port)'); + // Leg slot: its own board/alight yard, not the schedule's endpoints. + expect(html).toContain('Dire Dawa Port'); + expect(html).toContain('Adama'); + }); + it('lists loaded empty containers by number and states they are empty', () => { const schedule = { id: 'schedule-1', @@ -1264,6 +1487,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' }; @@ -1279,6 +1521,46 @@ describe('TrainSchedulingService', () => { expect(numbers).toEqual(['W-LEG2']); }); + it('drops a whole-route slot with no physical wagon pinned, even though its allocation is LOADED', () => { + // A booking can hold a LOADED allocation before a real wagon backs it + // (fleet shortfall left the slot unpinned), or a REAL cut nulls + // physicalWagonId without ever touching the slot's own status. Either + // way there is no physical wagon standing there to marshal. + const pinned = makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]); + const ghost = { ...makeWagon(2, 'W-002', [allocWith({ status: 'LOADED' })]), physicalWagonId: null }; + const schedule = { trainSet: { wagons: [pinned, ghost] }, scheduleBookings: [] }; + + const { wagons } = onBoardView(schedule); + const numbers = (wagons as Array<{ physicalWagon: { wagonNumber: string } }>).map( + (w) => w.physicalWagon.wagonNumber, + ); + expect(numbers).toEqual(['W-001']); + }); + + it('drops a leg slot LOADED by generation time but not yet coupled as of this stop', () => { + // Both W-DIRE (coupled+loaded at Dire Dawa) and W-ADAMA (coupled+loaded + // at Adama, a LATER stop) read identically to intercityOnBoardView by + // the time this runs — both LOADED right now. Only the adjustment log + // knows W-ADAMA hadn't coupled yet as of Dire Dawa's own timestamp. + const wholeRoute = makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]); + const legDireDawa = { ...makeWagon(2, 'W-DIRE', [allocWith({ status: 'LOADED' })]), boardYardId: 'yard-dire' }; + const legAdama = { ...makeWagon(3, 'W-ADAMA', [allocWith({ status: 'LOADED' })]), boardYardId: 'yard-adama' }; + const schedule = { trainSet: { wagons: [wholeRoute, legDireDawa, legAdama] }, scheduleBookings: [] }; + + const { wagons } = onBoardView(schedule); + const boardedByDireDawa = new Set(['W-DIRE']); // logged ADD only up to Dire Dawa's stop + const wagonsAsOfStop = (service as never as { + wagonsAsOfStop: (w: unknown, s: Set) => Array<{ physicalWagon: { wagonNumber: string } }>; + }).wagonsAsOfStop.bind(service); + + const asOfDireDawa = wagonsAsOfStop(wagons, boardedByDireDawa); + expect(asOfDireDawa.map((w) => w.physicalWagon.wagonNumber)).toEqual(['W-001', 'W-DIRE']); + + const boardedByAdama = new Set(['W-DIRE', 'W-ADAMA']); // both stops have now happened + const asOfAdama = wagonsAsOfStop(wagons, boardedByAdama); + expect(asOfAdama.map((w) => w.physicalWagon.wagonNumber)).toEqual(['W-001', 'W-DIRE', 'W-ADAMA']); + }); + it('lists an IN_TRANSIT booking with no wagon allocation in the unassigned section', () => { const rider = { id: 'booking-9', @@ -1671,6 +1953,8 @@ describe('TrainSchedulingService', () => { save: jest.fn().mockResolvedValue(undefined), create: jest.fn((x: unknown) => x), })), + // Wagon-history lookup of the released allocations' physical wagons. + query: jest.fn().mockResolvedValue([]), }; beforeEach(() => { 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 49aaad85c..4f416cdcf 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 @@ -6,6 +6,7 @@ TrainCheckpointKind, TrainScheduleStatus as TrainScheduleStatusEnum, WagonAllocationSnapshot, + WagonEventType, WagonMovementKind, WagonStatus, } from '@edr/types'; @@ -28,6 +29,7 @@ import { ILike, In, IsNull, + LessThanOrEqual, Not, QueryFailedError, Raw, @@ -71,6 +73,7 @@ import { Yard } from '../../rule-engine/entities/yard.entity'; import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository'; import { Wagon } from '../../wagons/entities/wagon.entity'; +import { WagonEventInput, WagonHistoryService } from '../../wagon-history/wagon-history.service'; import { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto'; import { AssignBookingsDto } from '../dto/assign-bookings.dto'; import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.dto'; @@ -421,8 +424,28 @@ export class TrainSchedulingService { @Optional() @Inject(forwardRef(() => BookingBatchService)) private readonly bookingBatchService?: BookingBatchService, + // Per-wagon history ledger (global module). @Optional keeps the positional + // spec constructors working; production always has it. + @Optional() private readonly wagonHistory?: WagonHistoryService, ) {} + /** Physical wagons behind a set of booking allocations (via their slots), for cargo history rows. */ + private async wagonsOfAllocations( + manager: EntityManager, + allocationIds: string[], + ): Promise> { + if (!allocationIds.length) return []; + return manager.query( + `SELECT a.id AS "allocationId", w.id AS "wagonId", w.wagon_number AS "wagonNumber", + w.current_yard_id AS "yardId", w.train_id AS "trainId" + FROM freight.wagon_booking_allocations a + JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id + JOIN freight.wagons w ON w.id = tsw.physical_wagon_id + WHERE a.id = ANY($1::uuid[])`, + [allocationIds], + ); + } + /** * Notify each booking's customer that their shipment was dispatched / arrived, * with a deep-link to the booking. Fire-and-forget — never blocks the action. @@ -442,8 +465,11 @@ export class TrainSchedulingService { relations: { company: true }, }); for (const b of bookings) { - if (event === 'dispatched') this.bookingNotifier.dispatched(b, origin, destination); - else this.bookingNotifier.arrived(b, origin, destination); + if (event === 'dispatched') { + this.bookingNotifier.dispatched(b, origin, destination, schedule); + } else { + this.bookingNotifier.arrived(b, origin, destination, schedule); + } } } catch (err) { this.logger.warn(`Failed to notify schedule bookings (${event}): ${(err as Error).message}`); @@ -1169,7 +1195,7 @@ export class TrainSchedulingService { }); for (const booking of allocatedBookings) { if (['CANCELLED', 'EXPIRED', 'REJECTED'].includes(booking.status)) continue; - this.bookingNotifier.rescheduled(booking, departure); + this.bookingNotifier.rescheduled(booking, departure, schedule); notifiedCount += 1; } } @@ -1374,7 +1400,7 @@ export class TrainSchedulingService { .getRepository(Booking) .update(aboard.map((b) => b.id), { scheduledDate: departure } as never); for (const booking of aboard) { - this.bookingNotifier.maintenanceMoved(booking, departure); + this.bookingNotifier.maintenanceMoved(booking, departure, schedule, dto.reason); } } @@ -1871,6 +1897,10 @@ export class TrainSchedulingService { status: TrainScheduleStatusEnum.Scheduled, direction, trainNumber: pairTrainNumber ?? undefined, + // Staff-entered at creation; the UI defaults it to the built train's + // own voyage number (Train.trainName). Fall back to the pair train + // number here only for non-UI callers that send none. + voyageNumber: dto.voyageNumber?.trim() || pairTrainNumber || null, maxWagons, plannedWagonYards, reverseWagonOrder: dto.reverseWagonOrder ?? false, @@ -2414,6 +2444,21 @@ export class TrainSchedulingService { manager, ); await this.wagonAllocationBulkLoadsRepository.deleteByAllocationIds(allocationIds, manager); + const carried = await this.wagonsOfAllocations(manager, allocationIds); + await this.wagonHistory?.record( + manager, + carried.map((c) => ({ + wagonId: c.wagonId, + wagonNumber: c.wagonNumber, + type: WagonEventType.BookingUnassigned, + actorUserId: userId ?? null, + fromYardId: c.yardId, + trainId: c.trainId, + trainScheduleId: scheduleId, + bookingId, + metadata: { allocationId: c.allocationId }, + })), + ); await manager.getRepository(WagonBookingAllocation).delete(allocationIds); } @@ -2480,6 +2525,18 @@ export class TrainSchedulingService { trainSetWagonId: null, status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, }); + await this.wagonHistory?.record(manager, { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.ReleasedFromSchedule, + actorUserId: userId ?? null, + fromYardId: wagon.currentYardId ?? null, + trainId: wagon.trainId ?? null, + trainScheduleId: scheduleId, + bookingId, + toValue: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, + reason: 'Booking unassigned from the dispatched train', + }); } } await manager.getRepository(TrainSetWagon).delete(slot.id); @@ -2530,7 +2587,8 @@ export class TrainSchedulingService { .getRepository(Booking) .findOne({ where: { id: bookingId }, relations: { company: true } }); if (removedBooking && opts.notifyCustomer !== false) { - this.bookingNotifier.removedFromTrain(removedBooking); + // The booking's train_schedule_id is already cleared — name the run explicitly. + this.bookingNotifier.removedFromTrain(removedBooking, schedule); } this.logger.log( `Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer notified to reschedule or cancel.`, @@ -2822,10 +2880,34 @@ export class TrainSchedulingService { // The pin lives ONLY on the schedule's slot — the Wagon entity keeps // its status untouched so other schedules can still use the wagon. + const previousPinId = slotById.get(assignment.trainSetWagonId)?.physicalWagonId ?? null; await manager.getRepository(TrainSetWagon).update(assignment.trainSetWagonId, { physicalWagonId: assignment.physicalWagonId, status: 'RESERVED', }); + if (previousPinId !== assignment.physicalWagonId) { + const pinEvents: WagonEventInput[] = [ + { + wagonId: assignment.physicalWagonId, + type: WagonEventType.PinnedToSchedule, + trainScheduleId: scheduleId, + trainId: builtTrainId ?? null, + fromYardId: schedule.originStationId ?? null, + metadata: { slotId: assignment.trainSetWagonId, auto: false }, + }, + ]; + if (previousPinId) { + pinEvents.push({ + wagonId: previousPinId, + type: WagonEventType.UnpinnedFromSchedule, + trainScheduleId: scheduleId, + trainId: builtTrainId ?? null, + reason: 'Replaced on the slot', + metadata: { slotId: assignment.trainSetWagonId }, + }); + } + await this.wagonHistory?.record(manager, pinEvents); + } for (const [physicalId, slotId] of slotIdByPhysicalId) { if (slotId === assignment.trainSetWagonId) { slotIdByPhysicalId.delete(physicalId); @@ -2991,9 +3073,27 @@ export class TrainSchedulingService { } // The train is out — every pinned wagon is ASSIGNED to this schedule and // stays pinned so no other schedule can pick it while it's rolling. - const dispatchedPhysicalIds = (schedule.trainSet?.wagons ?? []) + const pinnedDispatchIds = (schedule.trainSet?.wagons ?? []) .map((slot) => slot.physicalWagonId) .filter((id): id is string => Boolean(id)); + // A built train rolls with its WHOLE consist, not just the slots that + // carry cargo: an empty wagon coupled to the train is physically leaving + // the yard too. Binding only the pinned slots left those empties behind + // on `current_train_schedule_id`, so the checkpoint position fix (which + // filters on exactly that column) never moved them and they stayed + // recorded at the origin yard while the train they are hooked to + // travelled the corridor. + const consistPhysicalIds = schedule.trainSet?.trainId + ? ( + await manager.getRepository(Wagon).find({ + where: { trainId: schedule.trainSet.trainId }, + select: { id: true }, + }) + ).map((w) => w.id) + : []; + const dispatchedPhysicalIds = [ + ...new Set([...pinnedDispatchIds, ...consistPhysicalIds]), + ]; if (dispatchedPhysicalIds.length) { await manager .getRepository(Wagon) @@ -3001,6 +3101,25 @@ export class TrainSchedulingService { { id: In(dispatchedPhysicalIds) }, { status: WagonStatus.Assigned, currentTrainScheduleId: scheduleId }, ); + const dispatchedWagons = await manager.getRepository(Wagon).find({ + where: { id: In(dispatchedPhysicalIds) }, + select: { id: true, wagonNumber: true, currentYardId: true, trainId: true }, + }); + await this.wagonHistory?.record( + manager, + dispatchedWagons.map((w) => ({ + wagonId: w.id, + wagonNumber: w.wagonNumber, + type: WagonEventType.Dispatched, + occurredAt: now, + actorUserId: userId ?? null, + fromYardId: w.currentYardId ?? null, + trainId: w.trainId ?? schedule.trainSet?.trainId ?? null, + trainScheduleId: scheduleId, + toValue: WagonStatus.Assigned, + metadata: { destinationYardId: schedule.destinationStationId ?? null }, + })), + ); } // Planned couples boarding at the ORIGIN join the built train now — the // departure is the moment they are physically hooked on. Mid-route @@ -3038,6 +3157,32 @@ export class TrainSchedulingService { status: WagonStatus.Assigned, currentTrainScheduleId: scheduleId, }); + await this.wagonHistory?.record(manager, [ + { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.CoupledToTrain, + occurredAt: now, + actorUserId: userId ?? null, + fromYardId: coupleYardId, + trainId: dispatchTrainId, + trainScheduleId: scheduleId, + toValue: maxSeq, + reason: 'Planned couple at the origin yard', + metadata: { status: { from: wagon.status, to: WagonStatus.Assigned } }, + }, + { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.Dispatched, + occurredAt: now, + actorUserId: userId ?? null, + fromYardId: coupleYardId, + trainId: dispatchTrainId, + trainScheduleId: scheduleId, + toValue: WagonStatus.Assigned, + }, + ]); await manager.getRepository(ScheduleWagonAdjustmentLog).save( manager.getRepository(ScheduleWagonAdjustmentLog).create({ trainScheduleId: scheduleId, @@ -3063,6 +3208,15 @@ export class TrainSchedulingService { // that the operator didn't load individually are auto-loaded now — the // train is leaving with them. Mid-corridor boarders stay PAID until the // operator loads them at their own yard. + // + // When the client sends the confirmed list, loading is a MANUAL decision: + // only the ticked bookings are stamped loaded. Anything unticked was + // already unassigned above, but a booking can also sit here unticked and + // still attached (government, or one this predicate cannot shed) — those + // must not be auto-loaded, or an empty wagon rides as if it carried cargo. + // Absent (older clients) = auto-load every origin boarder, the historic + // behavior. + const confirmedLoadedIds = dto.loadedBookingIds; await manager.query( `UPDATE freight.bookings b SET status = 'IN_TRANSIT', @@ -3074,8 +3228,14 @@ export class TrainSchedulingService { AND b.deleted_at IS NULL AND b.origin_yard_id = $2 AND b.loaded_at IS NULL - AND (b.status = 'PAID' OR (b.is_government = true AND b.status = 'APPROVED'))`, - [scheduleId, schedule.originStationId, now], + AND (b.payment_status = 'PAID' OR (b.is_government = true AND b.status = 'APPROVED')) + AND ($4::uuid[] IS NULL OR b.id = ANY($4::uuid[]))`, + [ + scheduleId, + schedule.originStationId, + now, + confirmedLoadedIds ? confirmedLoadedIds : null, + ], ); // Close the booking window; any still-pending (unallocated) reservations don't ride this train. await manager @@ -3173,11 +3333,19 @@ export class TrainSchedulingService { context: { action: string; yardLabel?: string }, ): Promise { if (!schedule.trainSetId) return; - const rows: Array<{ reference: string; loaded: string; total: string }> = - await this.dataSource.query( - `SELECT b.reference, + const rows: Array<{ + bookingId: string; + reference: string; + loaded: string; + total: string; + unloadedAllocationIds: string[]; + }> = await this.dataSource.query( + `SELECT b.id AS "bookingId", + b.reference, COUNT(*) FILTER (WHERE a.status IN ('LOADED', 'DEPARTED')) AS loaded, - COUNT(*) AS total + COUNT(*) AS total, + ARRAY_AGG(a.id) FILTER (WHERE a.status NOT IN ('LOADED', 'DEPARTED')) + AS "unloadedAllocationIds" FROM freight.wagon_booking_allocations a JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id JOIN freight.bookings b ON b.id = a.booking_id @@ -3189,18 +3357,38 @@ export class TrainSchedulingService { GROUP BY b.id, b.reference HAVING COUNT(*) FILTER (WHERE a.status IN ('LOADED', 'DEPARTED')) > 0 AND COUNT(*) FILTER (WHERE a.status NOT IN ('LOADED', 'DEPARTED')) > 0`, - [schedule.trainSetId, boardingYardId], - ); + [schedule.trainSetId, boardingYardId], + ); if (rows.length) { const detail = rows .map((r) => `${r.reference} (${r.loaded}/${r.total} wagons loaded)`) .join(', '); const where = context.yardLabel ? ` at ${context.yardLabel}` : ''; - throw new BadRequestException( - `Cannot ${context.action}: booking(s) partially loaded${where} — load every wagon ` + + // The message stays human-readable for logs and older clients, but the + // payload carries the machine-readable cut so the UI can offer the + // EDR-fault / customer-fault decision instead of parsing prose. + throw new BadRequestException({ + statusCode: 400, + error: 'Bad Request', + code: 'PARTIALLY_LOADED_BOOKINGS', + message: + `Cannot ${context.action}: booking(s) partially loaded${where} — load every wagon ` + `or cancel the remainder (customer fault: cancellation fee; EDR fault: no fee, ` + `rebookable) first: ${detail}`, - ); + partiallyLoaded: { + scheduleId: schedule.id, + boardingYardId, + yardLabel: context.yardLabel ?? null, + action: context.action, + bookings: rows.map((r) => ({ + bookingId: r.bookingId, + reference: r.reference, + loadedWagons: Number(r.loaded), + totalWagons: Number(r.total), + unloadedAllocationIds: r.unloadedAllocationIds ?? [], + })), + }, + }); } } @@ -3227,6 +3415,20 @@ export class TrainSchedulingService { } } + /** + * The log-pass twin of {@link unloadedOriginBoarderIds}: bookings that boarded + * at `yardId` and are still unloaded once the train has left it. Same + * predicate — partially-loaded bookings (loading_started_at set) are excluded + * because assertNoPartiallyLoadedBookings resolves those first, and government + * bookings can never be shed. + */ + private async unloadedBoarderIdsAtYard( + scheduleId: string, + yardId: string, + ): Promise { + return this.unloadedOriginBoarderIds(scheduleId, yardId); + } + private async unloadedOriginBoarderIds( scheduleId: string, originYardId: string, @@ -3243,7 +3445,7 @@ export class TrainSchedulingService { AND b.loading_started_at IS NULL AND COALESCE(tsb.loading_status, 'UNLOADED') <> 'LOADED' AND b.is_government = false - AND (b.status = 'PAID' + AND (b.payment_status = 'PAID' OR (b.shipping_line_company_id IS NOT NULL AND b.status = 'FULLY_EXECUTED'))`, [scheduleId, originYardId], ); @@ -3357,7 +3559,7 @@ export class TrainSchedulingService { // milestone still counts as paid — the clearance views self-heal the row on // read, and the gate pass must not lag behind that. for (const booking of bookings) { - if (booking.paymentStatus === 'PAID' || booking.status === 'PAID') { + if (booking.paymentStatus === 'PAID') { paidBookingIds.add(booking.id); } } @@ -3481,6 +3683,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, @@ -3507,6 +3714,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, @@ -3547,7 +3756,21 @@ 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). + // Also doubles as the per-row Departure/Arrival Station lookup below. + const yardLabelById = await this.yardLabelsById( + (schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]), + ); + const pendingBoardYardLabelBySlot = new Map( + (schedule.trainSet?.wagons ?? []) + .filter((wagon) => wagon.boardYardId) + .map((wagon) => [wagon.id, yardLabelById.get(wagon.boardYardId!) ?? 'en route']), + ); + const html = this.buildExportLoadListHtml(schedule, { + pendingBoardYardLabelBySlot, + yardLabelById, emptyContainers: await this.loadedEmptyContainers(scheduleId), logoImageUrl: await this.logoSettings.getLogoImageUrl(), }); @@ -3565,8 +3788,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. */ @@ -3577,12 +3803,21 @@ export class TrainSchedulingService { const wagons = (schedule.trainSet?.wagons ?? []) .filter((wagon) => { if (wagon.status === 'DEPARTED') return false; + // No physical wagon pinned to the slot — a booking can hold an + // allocation before a real wagon backs it (e.g. a fleet shortfall + // left it unpinned). There is nothing physical here to marshal, and + // a REAL cut also lands here: it nulls physicalWagonId without ever + // touching this slot's own status, so a cut wagon would otherwise + // linger as a phantom row with its cargo still listed. + if (!wagon.physicalWagonId) return false; const hasLoaded = (wagon.allocations ?? []).some((a) => a.status === 'LOADED'); return wagon.boardYardId == null || hasLoaded; }) .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( @@ -3599,7 +3834,101 @@ export class TrainSchedulingService { return { wagons, unassignedBookings }; } - async intercityMarshallingDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> { + /** + * Corrects intercityOnBoardView's CURRENT-state wagon list against a + * specific stop's document. intercityOnBoardView's "boardYardId == null || + * hasLoaded" test reads whatever is true RIGHT NOW — it can't distinguish + * "this leg slot coupled at THIS stop" from "it coupled at a LATER stop + * that has, by generation time, also already happened" (both look LOADED). + * Reprinting an earlier stop's document after a later one has run would + * otherwise leak the later stop's wagons in. `boardedWagonNumbers` is the + * set of physical wagon numbers with a logged ADD at or before this stop + * (see marshallingDocumentAt) — the ground truth a real-time heuristic + * can't provide once multiple stops have already happened. + */ + private wagonsAsOfStop(wagons: TrainSetWagon[], boardedWagonNumbers: Set): TrainSetWagon[] { + return wagons.filter( + (wagon) => wagon.boardYardId == null || boardedWagonNumbers.has(wagon.physicalWagon?.wagonNumber ?? ''), + ); + } + + /** + * Every corridor stop where the consist actually changed for this schedule + * (coupled, uncoupled, or switched — any flavor), in the order the train + * reached them. Origin is never in this list — it's always its own doc (the + * plain import/export load list), so numbering here starts at 2. A stop with + * only a routine checkpoint and no consist change never gets a row, which is + * the point: "Marshalling 2, 3, 4…" tracks events, not raw stop count. + */ + async marshallingStops( + scheduleId: string, + ): Promise> { + const rows = await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({ + where: { trainScheduleId: scheduleId }, + order: { occurredAt: 'ASC' }, + }); + const firstSeenAt = new Map(); + for (const row of rows) { + if (!row.yardId || firstSeenAt.has(row.yardId)) continue; + firstSeenAt.set(row.yardId, row.occurredAt); + } + const orderedYardIds = [...firstSeenAt.entries()] + .sort((a, b) => a[1].getTime() - b[1].getTime()) + .map(([yardId]) => yardId); + const labels = await this.yardLabelsById(orderedYardIds); + return orderedYardIds.map((yardId, i) => ({ + stopIndex: i + 2, + yardId, + yardLabel: labels.get(yardId) ?? yardId, + firstOccurredAt: firstSeenAt.get(yardId)!.toISOString(), + })); + } + + /** + * The coupled/uncoupled/switched rows for one stop, in the locked table + * shape (wagon, event, containers). "EMPTY WAGON" replaces the container + * list rather than a blank cell — the column always exists so a loaded and + * an empty coupling read as the same table, not two different layouts. + * Cargo for ADD/REMOVE rows is read off the schedule's OWN slot allocations + * for that physical wagon: an ADD is a leg slot boarding already loaded (see + * stampSlotLegs) or an empty couple (plannedWagonCouples) with none; a + * REMOVE is a slot alighting with its cargo, or an empty trim. A SWITCH row + * carries the incoming wagon's id — the slot's cargo already rides it. + */ + private consistChangesAt( + schedule: TrainSchedule, + logRows: ScheduleWagonAdjustmentLog[], + ): Array<{ wagonNumber: string; event: 'Coupled' | 'Uncoupled' | 'Switched'; containerNumbers: string }> { + const slotByPhysicalWagonId = new Map( + (schedule.trainSet?.wagons ?? []) + .filter((wagon) => wagon.physicalWagonId) + .map((wagon) => [wagon.physicalWagonId as string, wagon]), + ); + return logRows.map((row) => { + const slot = slotByPhysicalWagonId.get(row.wagonId); + const containerNumbers = (slot?.allocations ?? []) + .flatMap((allocation) => allocation.containerItems ?? []) + .map((item) => item.containerNumber) + .filter(Boolean) + .join(', '); + return { + wagonNumber: row.wagonNumber, + event: row.action === 'ADD' ? 'Coupled' : row.action === 'REMOVE' ? 'Uncoupled' : 'Switched', + containerNumbers: containerNumbers || 'EMPTY WAGON', + }; + }); + } + + /** + * The numbered marshalling document for one corridor stop (see + * marshallingStops — stopIndex 2+, origin is its own separate doc). + * ponytail: the wagon table always shows the CURRENT on-board state, not a + * point-in-time reconstruction of what stood on the train at that past + * stop — a full historical snapshot is a much bigger feature nobody has + * asked for. What's stop-specific is the consist-changes table below it, + * which IS scoped to that stop's own logged events. + */ + async marshallingDocumentAt(scheduleId: string, stopIndex: number): Promise<{ filename: string; buffer: Buffer }> { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); @@ -3609,14 +3938,96 @@ export class TrainSchedulingService { 'Intercity marshalling document applies only to dispatched or arrived trains', ); } + const stops = await this.marshallingStops(scheduleId); + const stop = stops.find((s) => s.stopIndex === stopIndex); + if (!stop) { + throw new NotFoundException( + `No marshalling document at stop ${stopIndex} for this schedule — nothing coupled/uncoupled there, or the stop doesn't exist`, + ); + } + const { wagons: currentWagons, unassignedBookings } = this.intercityOnBoardView(schedule); + // intercityOnBoardView's "boardYardId == null || hasLoaded" test reads + // CURRENT state — it can't tell "coupled here" from "coupled at a LATER + // stop that has since also happened" (both look LOADED by generation + // time once the trip has moved past this stop). Reprinting Marshalling 2 + // after Marshalling 3's stop already ran would otherwise show Marshalling + // 3's coupled wagons too. Correct it against the log: a leg-slot wagon + // belongs on THIS stop's document only if it actually has a logged ADD + // at or before THIS stop's own timestamp. + const boardedByThisStop = new Set( + ( + await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({ + where: { + trainScheduleId: scheduleId, + action: 'ADD', + occurredAt: LessThanOrEqual(new Date(stop.firstOccurredAt)), + }, + }) + ).map((row) => row.wagonNumber), + ); + const wagons = this.wagonsAsOfStop(currentWagons, boardedByThisStop); + const logRows = await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({ + where: { trainScheduleId: scheduleId, yardId: stop.yardId }, + order: { occurredAt: 'ASC' }, + }); + // Per-row Departure/Arrival Station: a whole-route wagon reads the + // schedule's own origin/destination, a leg-slot wagon reads where IT + // boards/alights instead. + const yardLabelById = await this.yardLabelsById( + (schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]), + ); + const html = this.buildExportLoadListHtml(schedule, { + title: `Intercity Marshalling Document / Load List (Marshalling ${stopIndex})`, + positionLabel: `At ${stop.yardLabel}`, + wagons, + unassignedBookings, + emptyContainers: await this.loadedEmptyContainers(scheduleId), + logoImageUrl: await this.logoSettings.getLogoImageUrl(), + consistChangesAtStop: this.consistChangesAt(schedule, logRows), + yardLabelById, + }); + // Styled table-aware fallback (marshalling grid) — see importLoadListDocument. + const buffer = await this.pdfDocuments.renderTabularDocument(html, `Marshalling ${stopIndex}`); + const reference = schedule.trainNumber ?? schedule.id; + return { + filename: `marshalling-${stopIndex}-${this.safeDocumentName(reference)}.pdf`, + buffer, + }; + } + + /** + * Back-compat alias: the single "current" intercity doc (Marshalling 2) the + * old one-document-per-schedule UI calls. Resolves to the LATEST stop with + * a logged consist change; falls back to the current-position doc with no + * changes table when nothing has coupled/uncoupled yet (e.g. right after + * dispatch, before any mid-corridor stop). + */ + async intercityMarshallingDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> { + const stops = await this.marshallingStops(scheduleId); + const latest = stops[stops.length - 1]; + if (latest) { + return this.marshallingDocumentAt(scheduleId, latest.stopIndex); + } + + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (schedule.status !== 'DISPATCHED' && schedule.status !== 'ARRIVED') { + throw new BadRequestException( + 'Intercity marshalling document applies only to dispatched or arrived trains', + ); + } const checkpoints = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId); const last = checkpoints[checkpoints.length - 1]; const positionLabel = last ? `After ${last.yard?.label ?? last.yard?.code ?? 'checkpoint'}` : `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? 'origin'} — no checkpoint recorded`; - const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule); + const yardLabelById = await this.yardLabelsById( + (schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]), + ); const html = this.buildExportLoadListHtml(schedule, { title: 'Intercity Marshalling Document / Load List (Marshalling 2)', positionLabel, @@ -3624,6 +4035,7 @@ export class TrainSchedulingService { unassignedBookings, emptyContainers: await this.loadedEmptyContainers(scheduleId), logoImageUrl: await this.logoSettings.getLogoImageUrl(), + yardLabelById, }); // Styled table-aware fallback (marshalling grid) — see importLoadListDocument. const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list'); @@ -3672,6 +4084,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?: { @@ -3681,6 +4102,21 @@ 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; + // yardId → label, for the per-row Departure/Arrival Station columns + // (falls back to the schedule's own origin/destination when a wagon's + // boardYardId/alightYardId is null — i.e. it rides the whole corridor). + yardLabelById?: Map; + // Numbered marshalling docs only (see marshallingDocumentAt / + // consistChangesAt) — couples/uncouples/switches logged at THIS stop. + // Origin import/export docs never pass this, so they render no such box. + consistChangesAtStop?: Array<{ + wagonNumber: string; + event: 'Coupled' | 'Uncoupled' | 'Switched'; + containerNumbers: string; + }>; }, ): string { const esc = (value: unknown) => @@ -3694,10 +4130,20 @@ export class TrainSchedulingService { const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-'); const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking])); // The document is checked against the physical train, so it has to run in - // consist order — the relation comes back unordered. - const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])].sort( - (a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0), - ); + // consist order — the relation comes back unordered. Slots planned to + // couple at a LATER stop (pendingBoardYardLabelBySlot, origin docs only — + // intercity calls never pass it, their wagons list is already on-board + // only) are dropped here, not just tallied around: they are not part of + // the departing consist, so they get no row and no count on this document. + // Their own coupling shows up on THAT stop's own marshalling document. + const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])] + .filter((wagon) => !opts?.pendingBoardYardLabelBySlot?.get(wagon.id)) + // No physical wagon pinned to the slot (fleet shortfall left a booking's + // allocation unpinned, or a REAL cut nulled it out): nothing physical + // to marshal, so no row. Harmless no-op for the numbered docs, whose + // wagons list already went through intercityOnBoardView's own check. + .filter((wagon) => Boolean(wagon.physicalWagonId)) + .sort((a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0)); // Empties sit on wagons that carry no booking allocation, keyed by the wagon // slot recorded when they were loaded. const emptiesByWagon = new Map(); @@ -3708,15 +4154,28 @@ export class TrainSchedulingService { empty, ]); } + const originLabel = schedule.originStation?.label ?? schedule.originStation?.code; + const destinationLabel = schedule.destinationStation?.label ?? schedule.destinationStation?.code; const rows = wagons .flatMap((wagon) => { + // Departure/Arrival Station per row: a leg-slot wagon boards/alights + // somewhere other than the schedule's own endpoints; a whole-route + // wagon just reads origin/destination. + const departureLabel = wagon.boardYardId + ? (opts?.yardLabelById?.get(wagon.boardYardId) ?? 'en route') + : originLabel; + const arrivalLabel = wagon.alightYardId + ? (opts?.yardLabelById?.get(wagon.alightYardId) ?? 'en route') + : destinationLabel; // Wagon identity is the same on every row the wagon produces, loaded or not. const wagonCells = `${esc(wagon.sequenceNo)} ${esc(wagon.physicalWagon?.wagonNumber)} ${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)} ${esc(Number(wagon.lengthMeters || 0).toFixed(3))} ${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))} - ${esc(Number(wagon.capacityTons || 0).toFixed(3))}`; + ${esc(Number(wagon.capacityTons || 0).toFixed(3))} + ${esc(departureLabel)} + ${esc(arrivalLabel)}`; const allocations = wagon.allocations ?? []; // An empty wagon still runs in the consist, so it still gets a line. Staff // check this document against the physical train — a wagon with no row @@ -3740,7 +4199,7 @@ export class TrainSchedulingService { return [ ` ${wagonCells} - EMPTY — no cargo allocated + EMPTY — no cargo allocated `, ]; } @@ -3768,7 +4227,7 @@ export class TrainSchedulingService { // they are still physically on the train, so they get rows of their own. const unassigned = opts?.unassignedBookings ?? []; const unassignedRows = unassigned.length - ? `ON BOARD — WAGON NOT RECORDED` + + ? `ON BOARD — WAGON NOT RECORDED` + unassigned .map((booking) => { const containerNumbers = (booking.bookingContainers ?? []) @@ -3777,7 +4236,7 @@ export class TrainSchedulingService { .join(', '); const leg = `${booking.originYard?.label ?? booking.originYard?.code ?? '-'} → ${booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'}`; return ` - ${esc(booking.reference)} — ${esc(leg)} + ${esc(booking.reference)} — ${esc(leg)} ${esc(booking.cargoType?.cargoTypeName ?? booking.cargoType?.code)} ${esc(booking.company?.name)} ${esc(containerNumbers)} @@ -3799,7 +4258,9 @@ export class TrainSchedulingService { ); // 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. + // physically on the train, so they count, and are called out on their own + // tile. Cargo boarding downstream never enters this loop — `wagons` above + // already excludes those slots. let count40ft = 0, count20ft = 0; wagons.forEach((wagon) => { (wagon.allocations ?? []).forEach((allocation) => { @@ -3835,6 +4296,7 @@ export class TrainSchedulingService { .tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; } .tile strong { font-size: 11px; } ${logoImageCss()} + h2 { margin: 16px 0 6px; font-size: 12px; color: #0f766e; text-transform: uppercase; letter-spacing: .05em; } table { width: 100%; border-collapse: collapse; } th { background: #f8fafc; color: #475569; text-align: left; } th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; } @@ -3880,6 +4342,32 @@ export class TrainSchedulingService { ${opts?.positionLabel ? `
Current position${esc(opts.positionLabel)}
` : ''}
+ ${ + opts?.consistChangesAtStop?.length + ? `

Consist Changed At This Stop

+ + + + + + + + + + ${opts.consistChangesAtStop + .map( + (row) => ` + + + + `, + ) + .join('')} + +
Wagon NoEventContainer No
${esc(row.wagonNumber)}${esc(row.event)}${esc(row.containerNumbers)}
` + : '' + } + @@ -3889,6 +4377,8 @@ export class TrainSchedulingService { + + @@ -3897,7 +4387,7 @@ export class TrainSchedulingService { - ${rows || ''} + ${rows || ''} ${unassignedRows}
Equated Length Tare Weight Load CapacityDeparture StationArrival Station Cargo Type Company Container No
No wagons on this train set.
No wagons on this train set.
@@ -4043,17 +4533,24 @@ export class TrainSchedulingService { .replace(/'/g, '''); const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleString('en-GB') : '-'); const status = loadList.operation.status; - 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), + // A leg slot (boardYard set) couples mid-corridor — it is not part of the + // consist this Djibouti-side document is checked against yet, so it gets + // no row and no count here at all. Its own coupling shows up on THAT + // stop's own marshalling document once it actually happens. Same for a + // slot with no physical wagon pinned at all — a booking can hold an + // allocation before a real wagon backs it (fleet shortfall), or a REAL + // cut nulled it out; either way there is nothing physical to marshal. + const wagons = loadList.wagons.filter((wagon) => !wagon.boardYard && wagon.wagonNumber != null); + const totalAllocations = wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0); + const totalWeight = wagons.reduce( + (sum, wagon) => sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0), 0, ); - const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length; + const emptyWagons = wagons.filter((wagon) => wagon.allocations.length === 0).length; // Container count summary (40ft, 20ft) let count40ft = 0, count20ft = 0; - loadList.wagons.forEach((wagon) => { + wagons.forEach((wagon) => { wagon.allocations.forEach((allocation) => { (allocation.containerItems ?? []).forEach((item) => { const size = this.resolveContainerItemSize(item); @@ -4063,15 +4560,15 @@ export class TrainSchedulingService { }); }); - const allocationRows = loadList.wagons + const allocationRows = wagons .flatMap((wagon) => { const wagonCells = `${esc(wagon.sequenceNo)} ${esc(wagon.wagonNumber)} ${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)}`; // An empty wagon still runs in the consist, so it still gets a line — see // buildExportLoadListHtml. if (wagon.allocations.length === 0) { @@ -4163,7 +4660,7 @@ export class TrainSchedulingService {
Origin${esc(loadList.origin)}
Destination${esc(loadList.destination)}
Total bookings${esc(loadList.totalBookings)}
-
Wagons${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}
+
Wagons${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}
Allocations${esc(totalAllocations)}
Total weight${esc(totalWeight.toFixed(3))} T
Containers 40ft${esc(count40ft)}
@@ -4702,6 +5199,24 @@ export class TrainSchedulingService { // skipped checkpoint log cannot smuggle an unresolved yard past the gate. await this.assertPassedYardsFullyLoaded(schedule, stations, dto.sequenceNo); + // Mid-corridor leave-behind. Recording THIS station means the train has + // left the previous one, so cargo that boarded back there has had its last + // chance to load: anything the operator did not tick is deallocated and + // returned to the pool, exactly as dispatch does for the origin yard. + // Origin (seq 0) is dispatch's job, so only seq >= 1 has a departed yard. + if (dto.loadedBookingIds && dto.sequenceNo > 0) { + const departedYardId = stations.find( + (s) => s.sequenceNo === dto.sequenceNo - 1, + )?.yardId; + if (departedYardId) { + const keep = new Set(dto.loadedBookingIds); + const candidates = await this.unloadedBoarderIdsAtYard(scheduleId, departedYardId); + for (const bookingId of candidates.filter((id) => !keep.has(id))) { + await this.unassignBooking(scheduleId, bookingId, undefined); + } + } + } + // Upsert by (scheduleId, sequenceNo) so re-logging a station updates rather than duplicates. const [existing] = await this.trainCheckpointEventsRepository.findAll({ where: { trainScheduleId: scheduleId, sequenceNo: dto.sequenceNo }, @@ -4788,6 +5303,7 @@ export class TrainSchedulingService { ); const adjustmentRows: ScheduleWagonAdjustmentLog[] = []; const movementRows: WagonMovement[] = []; + const historyRows: WagonEventInput[] = []; let realCutHappened = false; for (const [wagonId, cutYardId] of cutNow) { const wagon = cutWagonById.get(wagonId); @@ -4795,6 +5311,31 @@ export class TrainSchedulingService { if (!wagon || wagon.currentTrainScheduleId !== scheduleId) continue; if (realCutIds.has(wagonId) && builtTrainId) { // REAL cut: the built train permanently loses the wagon here. + historyRows.push( + { + wagonId, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.CutAtYard, + occurredAt, + fromYardId: scheduleYardOf(schedule.plannedWagonYards, wagon) ?? schedule.originStationId ?? null, + toYardId: cutYardId, + trainId: builtTrainId, + trainScheduleId: scheduleId, + toValue: WagonStatus.Available, + metadata: { permanent: true }, + }, + { + wagonId, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.UncoupledFromTrain, + occurredAt, + fromYardId: cutYardId, + trainId: builtTrainId, + trainScheduleId: scheduleId, + fromValue: wagon.sequenceNumber, + reason: 'Cut from the train at this yard (permanent)', + }, + ); await manager.getRepository(Wagon).update(wagonId, { currentYardId: cutYardId, currentTrainScheduleId: null, @@ -4827,6 +5368,18 @@ export class TrainSchedulingService { realCutHappened = true; } else { // Soft cut: sits out the rest of this trip, stays in the build. + historyRows.push({ + wagonId, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.CutAtYard, + occurredAt, + fromYardId: scheduleYardOf(schedule.plannedWagonYards, wagon) ?? schedule.originStationId ?? null, + toYardId: cutYardId, + trainId: wagon.trainId ?? null, + trainScheduleId: scheduleId, + toValue: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, + metadata: { permanent: false }, + }); await manager.getRepository(Wagon).update(wagonId, { currentYardId: cutYardId, currentTrainScheduleId: null, @@ -4851,6 +5404,7 @@ export class TrainSchedulingService { if (movementRows.length) { await manager.getRepository(WagonMovement).save(movementRows); } + await this.wagonHistory?.record(manager, historyRows); // Keep the coupling order gapless after permanent removals. if (realCutHappened && builtTrainId) { const remaining = await manager.getRepository(Wagon).find({ @@ -4904,6 +5458,18 @@ export class TrainSchedulingService { status: WagonStatus.Assigned, currentTrainScheduleId: scheduleId, }); + await this.wagonHistory?.record(manager, { + wagonId, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.CoupledToTrain, + occurredAt, + fromYardId: coupleYardId, + trainId: builtTrainId, + trainScheduleId: scheduleId, + toValue: maxSeq, + reason: 'Planned couple at a mid-route stop', + metadata: { status: { from: wagon.status, to: WagonStatus.Assigned } }, + }); coupleLogRows.push( manager.getRepository(ScheduleWagonAdjustmentLog).create({ trainScheduleId: scheduleId, @@ -4921,6 +5487,90 @@ export class TrainSchedulingService { await manager.getRepository(ScheduleWagonAdjustmentLog).save(coupleLogRows); } } + // Which wagons the position fix below will actually move — read first + // so each gets its own PASSED_CHECKPOINT history row (from → to yard). + const riding = await manager + .getRepository(Wagon) + .createQueryBuilder('w') + .select(['w.id', 'w.wagonNumber', 'w.currentYardId', 'w.trainId']) + .where('w.current_train_schedule_id = :scheduleId', { scheduleId }) + .andWhere('(w.current_yard_id IS NULL OR w.current_yard_id IN (:...passedYardIds))', { + passedYardIds, + }) + .andWhere('w.current_yard_id IS DISTINCT FROM :stationYardId', { + stationYardId: station.yardId, + }) + .getMany(); + + // Leg slots (booking legs boarding/alighting mid-corridor — see + // stampSlotLegs) reaching their board/alight yard here: logged same as + // planned couples/cuts above, so the marshalling document can show + // what coupled ALREADY LOADED / uncoupled WITH cargo at this stop. + // Purely observational — their physical wagon was already pinned to + // the slot at schedule-build time (assignPhysicalWagonsToSlots), so + // nothing here changes wagon state, only the log. Dedupe against + // existing rows (not a wagon-state flag, unlike the couple/cut blocks + // above) since passedYardIds re-includes earlier stops on every call. + const legSlotsHere = (schedule.trainSet?.wagons ?? []).filter( + (slot) => + slot.physicalWagonId && + ((slot.boardYardId && passedYardIds.includes(slot.boardYardId)) || + (slot.alightYardId && passedYardIds.includes(slot.alightYardId))), + ); + if (legSlotsHere.length && builtTrainId) { + const legWagonIds = [ + ...new Set(legSlotsHere.map((slot) => slot.physicalWagonId!)), + ]; + const legWagonById = new Map( + ( + await manager.getRepository(Wagon).find({ where: { id: In(legWagonIds) } }) + ).map((w) => [w.id, w]), + ); + const alreadyLogged = new Set( + ( + await manager.getRepository(ScheduleWagonAdjustmentLog).find({ + where: { + trainScheduleId: scheduleId, + wagonId: In(legWagonIds), + action: In(['ADD', 'REMOVE']), + }, + }) + ).map((row) => `${row.wagonId}:${row.action}:${row.yardId}`), + ); + const legLogRows: ScheduleWagonAdjustmentLog[] = []; + for (const slot of legSlotsHere) { + const wagon = legWagonById.get(slot.physicalWagonId!); + if (!wagon) continue; + const events: Array<{ action: 'ADD' | 'REMOVE'; yardId: string }> = []; + if (slot.boardYardId && passedYardIds.includes(slot.boardYardId)) { + events.push({ action: 'ADD', yardId: slot.boardYardId }); + } + if (slot.alightYardId && passedYardIds.includes(slot.alightYardId)) { + events.push({ action: 'REMOVE', yardId: slot.alightYardId }); + } + for (const { action, yardId } of events) { + const key = `${wagon.id}:${action}:${yardId}`; + if (alreadyLogged.has(key)) continue; + alreadyLogged.add(key); + legLogRows.push( + manager.getRepository(ScheduleWagonAdjustmentLog).create({ + trainScheduleId: scheduleId, + trainId: builtTrainId, + action, + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + adjustedByUserId: null, + yardId, + occurredAt, + }), + ); + } + } + if (legLogRows.length) { + await manager.getRepository(ScheduleWagonAdjustmentLog).save(legLogRows); + } + } + await manager .getRepository(Wagon) .createQueryBuilder() @@ -4933,6 +5583,20 @@ export class TrainSchedulingService { passedYardIds, }) .execute(); + await this.wagonHistory?.record( + manager, + riding.map((w) => ({ + wagonId: w.id, + wagonNumber: w.wagonNumber, + type: WagonEventType.PassedCheckpoint, + occurredAt, + fromYardId: w.currentYardId ?? null, + toYardId: station.yardId, + trainId: w.trainId ?? schedule.trainSet?.trainId ?? null, + trainScheduleId: scheduleId, + metadata: { sequenceNo: dto.sequenceNo, kind: dto.kind ?? null }, + })), + ); if (schedule.trainSet?.trainId) { await manager .getRepository(Train) @@ -5159,6 +5823,7 @@ export class TrainSchedulingService { ); const arrivalLogRows: ScheduleWagonAdjustmentLog[] = []; const arrivalMovementRows: WagonMovement[] = []; + const arrivalHistoryRows: WagonEventInput[] = []; for (const slot of schedule.trainSet?.wagons ?? []) { if (!slot.physicalWagonId) continue; const wagon = settleWagonById.get(slot.physicalWagonId); @@ -5182,6 +5847,32 @@ export class TrainSchedulingService { // Arrival fallback for a journey logged without mid-route // checkpoints: the REAL cut still permanently removes the wagon // from the built train at its cut yard. + arrivalHistoryRows.push( + { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.CutAtYard, + occurredAt: now, + fromYardId: slot.boardYardId ?? schedule.originStationId ?? null, + toYardId: settleYardId, + trainId: ownerTrainId, + trainScheduleId: scheduleId, + bookingId: (slot.allocations ?? [])[0]?.bookingId ?? null, + toValue: WagonStatus.Available, + metadata: { permanent: true, atArrival: true }, + }, + { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.UncoupledFromTrain, + occurredAt: now, + fromYardId: settleYardId, + trainId: ownerTrainId, + trainScheduleId: scheduleId, + fromValue: wagon.sequenceNumber, + reason: 'Cut from the train at its planned yard (permanent)', + }, + ); await manager.getRepository(Wagon).update(wagon.id, { currentTrainScheduleId: null, trainSetWagonId: null, @@ -5211,6 +5902,19 @@ export class TrainSchedulingService { }), ); } else { + arrivalHistoryRows.push({ + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.SettledOnArrival, + occurredAt: now, + fromYardId: slot.boardYardId ?? schedule.originStationId ?? null, + toYardId: settleYardId, + trainId: wagon.trainId ?? null, + trainScheduleId: scheduleId, + bookingId: (slot.allocations ?? [])[0]?.bookingId ?? null, + toValue: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, + metadata: { slotId: slot.id, loaded: (slot.allocations ?? []).length > 0 }, + }); await manager.getRepository(Wagon).update(wagon.id, { currentTrainScheduleId: null, trainSetWagonId: null, @@ -5262,6 +5966,18 @@ export class TrainSchedulingService { if (!wagon) continue; if (wagon.currentTrainScheduleId === scheduleId) { // Joined during the trip, slot-less: settle at the destination. + arrivalHistoryRows.push({ + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.SettledOnArrival, + occurredAt: now, + fromYardId: coupleYardId, + toYardId: schedule.destinationStationId ?? null, + trainId: wagon.trainId ?? null, + trainScheduleId: scheduleId, + toValue: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, + metadata: { loaded: false, coupledMidRoute: true }, + }); await manager.getRepository(Wagon).update(wagon.id, { currentTrainScheduleId: null, trainSetWagonId: null, @@ -5299,6 +6015,32 @@ export class TrainSchedulingService { status: WagonStatus.Assigned, currentYardId: schedule.destinationStationId, }); + arrivalHistoryRows.push( + { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.CoupledToTrain, + occurredAt: now, + fromYardId: coupleYardId, + trainId: arrivalTrainId, + trainScheduleId: scheduleId, + toValue: arrivalMaxSeq, + reason: 'Planned couple joined on arrival', + metadata: { status: { from: wagon.status, to: WagonStatus.Assigned } }, + }, + { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.SettledOnArrival, + occurredAt: now, + fromYardId: coupleYardId, + toYardId: schedule.destinationStationId ?? null, + trainId: arrivalTrainId, + trainScheduleId: scheduleId, + toValue: WagonStatus.Assigned, + metadata: { loaded: false, coupledMidRoute: true }, + }, + ); arrivalLogRows.push( manager.getRepository(ScheduleWagonAdjustmentLog).create({ trainScheduleId: scheduleId, @@ -5323,9 +6065,43 @@ export class TrainSchedulingService { ); } } + // Consist-only empties: coupled to the built train and bound at dispatch + // so the checkpoint position fix moves them, but they own no slot, so the + // per-slot settle above never sees them. Release them here or they stay + // locked to a finished schedule and no later train can pick them up. + // They carry no cargo, so they simply settle where the train ended up. + const looseEmpties = await manager.getRepository(Wagon).find({ + where: { currentTrainScheduleId: scheduleId }, + select: { id: true, wagonNumber: true, currentYardId: true, trainId: true }, + }); + arrivalHistoryRows.push( + ...looseEmpties.map((w) => ({ + wagonId: w.id, + wagonNumber: w.wagonNumber, + type: WagonEventType.SettledOnArrival, + occurredAt: now, + fromYardId: w.currentYardId ?? null, + toYardId: schedule.destinationStationId ?? null, + trainId: w.trainId ?? null, + trainScheduleId: scheduleId, + metadata: { loaded: false, consistOnly: true }, + })), + ); + await manager + .getRepository(Wagon) + .createQueryBuilder() + .update(Wagon) + .set({ + currentTrainScheduleId: null, + trainSetWagonId: null, + currentYardId: schedule.destinationStationId, + }) + .where('current_train_schedule_id = :scheduleId', { scheduleId }) + .execute(); if (arrivalLogRows.length) { await manager.getRepository(ScheduleWagonAdjustmentLog).save(arrivalLogRows); } + await this.wagonHistory?.record(manager, arrivalHistoryRows); if (arrivalMovementRows.length) { await manager.getRepository(WagonMovement).save(arrivalMovementRows); } @@ -5522,6 +6298,19 @@ export class TrainSchedulingService { } for (const wagon of schedule.trainSet?.wagons ?? []) { if (wagon.physicalWagonId) { + await this.wagonHistory?.record(manager, { + wagonId: wagon.physicalWagonId, + wagonNumber: wagon.physicalWagon?.wagonNumber ?? null, + type: WagonEventType.ReturnedOnCancel, + actorUserId: userId ?? null, + fromYardId: wagon.physicalWagon?.currentYardId ?? null, + toYardId: schedule.originStationId ?? null, + trainId: wagon.physicalWagon?.trainId ?? null, + trainScheduleId: id, + toValue: wagon.physicalWagon?.trainId ? WagonStatus.Assigned : WagonStatus.Available, + reason: dto?.reason?.trim() || 'Schedule cancelled', + metadata: { slotId: wagon.id }, + }); await manager.getRepository(Wagon).update(wagon.physicalWagonId, { currentTrainScheduleId: null, trainSetWagonId: null, @@ -5558,7 +6347,8 @@ export class TrainSchedulingService { const booking = await this.bookingsRepository .findByIdWithFiles(sb.bookingId) .catch(() => null); - if (booking) this.bookingNotifier.scheduleCancelled(booking); + // Detached above, so pass the cancelled schedule for its train/voyage numbers. + if (booking) this.bookingNotifier.scheduleCancelled(booking, schedule); } // Window retired (DONE) — remove the card from portal/GL lists right away. @@ -6498,6 +7288,15 @@ export class TrainSchedulingService { physicalWagonId: physical.id, status: 'RESERVED', }); + await this.wagonHistory?.record(manager, { + wagonId: physical.id, + wagonNumber: physical.wagonNumber, + type: WagonEventType.PinnedToSchedule, + trainScheduleId: scheduleId, + trainId: builtTrainId ?? null, + fromYardId: physical.currentYardId ?? null, + metadata: { slotId: slot.trainSetWagonId, auto: true }, + }); const pinnedSpans = occupiedSpans.get(physical.id) ?? []; pinnedSpans.push(span); occupiedSpans.set(physical.id, pinnedSpans); @@ -8574,6 +9373,21 @@ export class TrainSchedulingService { for (const wagon of removed) { await manager.getRepository(Wagon).update(wagon.id, detachPatch); } + const consistReason = (dto as { reason?: string | null }).reason?.trim() || null; + await this.wagonHistory?.record( + manager, + removed.map((wagon) => ({ + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.UncoupledFromTrain, + actorUserId: userId ?? null, + trainId: train.id, + trainScheduleId: scheduleId, + fromYardId: currentYardId ?? null, + fromValue: wagon.sequenceNumber, + reason: consistReason ?? 'Trimmed from the consist on the schedule', + })), + ); if (removed.length && ownSetIds.length) { // This train's own pins (all its runs) on trimmed wagons are stale — // clear them so the freed wagon isn't still claimed by slots it left. @@ -8611,6 +9425,32 @@ export class TrainSchedulingService { // Mirror on the in-memory row — the compaction below sorts by it. to.sequenceNumber = from.sequenceNumber; await manager.getRepository(Wagon).update(from.id, detachPatch); + await this.wagonHistory?.record(manager, [ + { + wagonId: to.id, + wagonNumber: to.wagonNumber, + type: WagonEventType.CoupledToTrain, + actorUserId: userId ?? null, + trainId: train.id, + trainScheduleId: scheduleId, + fromYardId: to.currentYardId ?? null, + toValue: from.sequenceNumber, + reason: consistReason ?? `Switched in for ${from.wagonNumber}`, + metadata: { replaced: from.wagonNumber, replacedWagonId: from.id }, + }, + { + wagonId: from.id, + wagonNumber: from.wagonNumber, + type: WagonEventType.UncoupledFromTrain, + actorUserId: userId ?? null, + trainId: train.id, + trainScheduleId: scheduleId, + fromYardId: currentYardId ?? null, + fromValue: from.sequenceNumber, + reason: consistReason ?? `Switched out for ${to.wagonNumber}`, + metadata: { replacedBy: to.wagonNumber, replacedByWagonId: to.id }, + }, + ]); } const remaining = consist.filter( @@ -8626,6 +9466,7 @@ export class TrainSchedulingService { } } let sequence = compacted.length; + const addedEvents: WagonEventInput[] = []; for (const wagon of added) { sequence += 1; await manager.getRepository(Wagon).update(wagon.id, { @@ -8633,7 +9474,20 @@ export class TrainSchedulingService { sequenceNumber: sequence, status: WagonStatus.Assigned, }); + addedEvents.push({ + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.CoupledToTrain, + actorUserId: userId ?? null, + trainId: train.id, + trainScheduleId: scheduleId, + fromYardId: wagon.currentYardId ?? null, + toValue: sequence, + reason: consistReason ?? 'Added to the consist on the schedule', + metadata: { status: { from: wagon.status, to: WagonStatus.Assigned } }, + }); } + await this.wagonHistory?.record(manager, addedEvents); // The schedule is full when every consist wagon is allocated. await manager @@ -10162,6 +11016,8 @@ export class TrainSchedulingService { // without the wagons' tare. The legs tab shows this per booking. cargoWeightTons: sb.booking ? bookingCargoTons(sb.booking) : 0, status: sb.booking?.status ?? null, + // Loadability is decided by the payment status, not `status`. + paymentStatus: sb.booking?.paymentStatus ?? null, schedulingStatus: sb.booking?.schedulingStatus ?? null, freightType: sb.booking?.freightType ?? null, // Which leg of the corridor this booking rides — the workspace can't @@ -11157,6 +12013,30 @@ export class TrainSchedulingService { await allocs.update(alloc.id, { trainSetWagonId: created.id }); } await slotRepo.update(source.id, emptyLoadFields); + await this.wagonHistory?.record(manager, [ + ...(source.physicalWagonId + ? [ + { + wagonId: source.physicalWagonId, + wagonNumber: source.physicalWagon?.wagonNumber ?? null, + type: WagonEventType.LoadMovedOut, + trainScheduleId: scheduleId, + bookingId: sourceAllocs[0]?.bookingId ?? null, + toValue: consistWagon.wagonNumber, + metadata: { toWagonId: consistWagon.id, allocations: sourceAllocs.length }, + }, + ] + : []), + { + wagonId: consistWagon.id, + wagonNumber: consistWagon.wagonNumber, + type: WagonEventType.LoadMovedIn, + trainScheduleId: scheduleId, + bookingId: sourceAllocs[0]?.bookingId ?? null, + fromValue: source.physicalWagon?.wagonNumber ?? null, + metadata: { fromWagonId: source.physicalWagonId ?? null, allocations: sourceAllocs.length }, + }, + ]); return; } @@ -11171,6 +12051,54 @@ export class TrainSchedulingService { } await slotRepo.update(target.id, sourceLoadFields); await slotRepo.update(source.id, targetLoadFields); + const moveEvents: WagonEventInput[] = []; + if (source.physicalWagonId) { + moveEvents.push({ + wagonId: source.physicalWagonId, + wagonNumber: source.physicalWagon?.wagonNumber ?? null, + type: WagonEventType.LoadMovedOut, + trainScheduleId: scheduleId, + bookingId: sourceAllocs[0]?.bookingId ?? null, + toValue: target.physicalWagon?.wagonNumber ?? null, + metadata: { toWagonId: target.physicalWagonId ?? null, allocations: sourceAllocs.length, swap: targetAllocs.length > 0 }, + }); + } + if (target.physicalWagonId) { + moveEvents.push({ + wagonId: target.physicalWagonId, + wagonNumber: target.physicalWagon?.wagonNumber ?? null, + type: WagonEventType.LoadMovedIn, + trainScheduleId: scheduleId, + bookingId: sourceAllocs[0]?.bookingId ?? null, + fromValue: source.physicalWagon?.wagonNumber ?? null, + metadata: { fromWagonId: source.physicalWagonId ?? null, allocations: sourceAllocs.length, swap: targetAllocs.length > 0 }, + }); + } + if (targetAllocs.length) { + if (target.physicalWagonId) { + moveEvents.push({ + wagonId: target.physicalWagonId, + wagonNumber: target.physicalWagon?.wagonNumber ?? null, + type: WagonEventType.LoadMovedOut, + trainScheduleId: scheduleId, + bookingId: targetAllocs[0]?.bookingId ?? null, + toValue: source.physicalWagon?.wagonNumber ?? null, + metadata: { toWagonId: source.physicalWagonId ?? null, allocations: targetAllocs.length, swap: true }, + }); + } + if (source.physicalWagonId) { + moveEvents.push({ + wagonId: source.physicalWagonId, + wagonNumber: source.physicalWagon?.wagonNumber ?? null, + type: WagonEventType.LoadMovedIn, + trainScheduleId: scheduleId, + bookingId: targetAllocs[0]?.bookingId ?? null, + fromValue: target.physicalWagon?.wagonNumber ?? null, + metadata: { fromWagonId: target.physicalWagonId ?? null, allocations: targetAllocs.length, swap: true }, + }); + } + } + await this.wagonHistory?.record(manager, moveEvents); }); return this.getTrainScheduleById(scheduleId); @@ -11475,7 +12403,11 @@ export class TrainSchedulingService { return assignability.shortage; } - /** Paid (or government) bookings that may be loaded onto wagons — excludes expired / awaiting payment. */ + /** + * Paid (or government) bookings that may be loaded onto wagons — excludes + * expired / awaiting payment. "Paid" is read from the PAYMENT status only; + * the booking status is not a reliable payment signal. + */ private isReadyToLoadBooking(booking: { status: string; paymentStatus?: string | null; @@ -11485,7 +12417,7 @@ export class TrainSchedulingService { if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') { return false; } - if (booking.status === 'PAID' || booking.paymentStatus === 'PAID') return true; + if (booking.paymentStatus === 'PAID') return true; if (booking.isGovernment) return true; return false; } @@ -11865,6 +12797,12 @@ export class TrainSchedulingService { // 2. The physical wagons follow the train — the target's stay put, and // EVERY wagon on the source train (coupled or loose) moves across so // nothing strands on the deactivated train. + const mergedFromSource = sourceTrainId + ? await manager.getRepository(Wagon).find({ + where: { trainId: sourceTrainId }, + select: { id: true, wagonNumber: true, currentYardId: true }, + }) + : []; if (incomingWagons.length) { await manager.getRepository(Wagon).update( { id: In(incomingWagons.map((w) => w.id)) }, @@ -11876,6 +12814,20 @@ export class TrainSchedulingService { .getRepository(Wagon) .update({ trainId: sourceTrainId }, { trainId: targetTrain.id }); } + await this.wagonHistory?.record( + manager, + mergedFromSource.map((w) => ({ + wagonId: w.id, + wagonNumber: w.wagonNumber, + type: WagonEventType.TrainMerged, + fromYardId: w.currentYardId ?? null, + trainId: targetTrain.id, + trainScheduleId: schedule.id, + fromValue: sourceTrainId, + toValue: targetTrain.code, + reason: `Train merged into ${targetTrain.code}`, + })), + ); // 3. Carry the target's train-set wagon rows into THIS consist, appended // after the existing wagons. Sequence is provisional — staff reorder 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 26c29499d..8160127b0 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 @@ -1,4 +1,4 @@ -import { Freight, WagonMovementKind, WagonStatus } from '@edr/types'; +import { Freight, WagonEventType, WagonMovementKind, WagonStatus } from '@edr/types'; import { BadRequestException, ConflictException, @@ -25,6 +25,7 @@ import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; import { WagonStatusLog } from '../wagons/entities/wagon-status-log.entity'; import { Wagon } from '../wagons/entities/wagon.entity'; +import { WagonEventInput, WagonHistoryService } from '../wagon-history/wagon-history.service'; import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto'; import { BuildTrainDto } from './dto/build-train.dto'; import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto'; @@ -76,6 +77,7 @@ export class TrainBuilderService { constructor( private readonly dataSource: DataSource, private readonly bookingBatchService: BookingBatchService, + private readonly wagonHistory: WagonHistoryService, ) {} async buildTrain(dto: BuildTrainDto) { @@ -134,7 +136,7 @@ export class TrainBuilderService { await this.replaceLocomotiveLinks(manager, train.id, locomotiveIds); if (dto.wagonIds?.length) { - await this.attachWagons(manager, train, dto.wagonIds, 0); + await this.attachWagons(manager, train, dto.wagonIds, 0, null); } return train.id; }); @@ -684,8 +686,19 @@ export class TrainBuilderService { wagon.currentYardId === previousYardId, ); const now = new Date(); + const events: WagonEventInput[] = []; for (const wagon of wagons) { if (wagon.currentYardId === yard.id) continue; + events.push({ + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.MovedWithTrain, + occurredAt: now, + fromYardId: wagon.currentYardId ?? null, + toYardId: yard.id, + trainId: train.id, + reason: `Train ${train.code} relocated`, + }); await manager.getRepository(Wagon).update(wagon.id, { currentYardId: yard.id }); // Ledger row keeps the wagon's yard history auditable (mirrors the // manual-relocation path in the wagons service). @@ -699,6 +712,7 @@ export class TrainBuilderService { }), ); } + await this.wagonHistory.record(manager, events); }); return this.getComposition(id); } @@ -735,6 +749,16 @@ export class TrainBuilderService { occurredAt: new Date(), }), ); + await this.wagonHistory.record(manager, { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.MovedManually, + actorUserId: userId ?? null, + fromYardId: wagon.currentYardId ?? null, + toYardId: yard.id, + trainId: train.id, + reason: 'Coupled wagon moved from the train builder', + }); }); return this.getComposition(id); } @@ -787,6 +811,19 @@ export class TrainBuilderService { await manager .getRepository(Wagon) .update(moving.map((w) => w.id), { currentYardId: yard.id }); + await this.wagonHistory.record( + manager, + moving.map((w) => ({ + wagonId: w.id, + wagonNumber: w.wagonNumber, + type: WagonEventType.MovedManually, + actorUserId: userId ?? null, + fromYardId: w.currentYardId ?? null, + toYardId: yard.id, + trainId: train.id, + reason: 'Coupled wagons moved from the train builder', + })), + ); await manager.getRepository(WagonMovement).save( moving.map((w) => manager.getRepository(WagonMovement).create({ @@ -810,7 +847,7 @@ export class TrainBuilderService { const currentCount = await manager .getRepository(Wagon) .count({ where: { trainId: train.id } }); - const attached = await this.attachWagons(manager, train, dto.wagonIds, currentCount); + const attached = await this.attachWagons(manager, train, dto.wagonIds, currentCount, userId ?? null); return this.syncLiveScheduleAfterConsistChange( manager, train.id, @@ -949,6 +986,18 @@ export class TrainBuilderService { }), ); } + await this.wagonHistory.record(manager, { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.StatusChanged, + actorUserId: userId ?? null, + trainId: train.id, + fromYardId: wagon.currentYardId ?? train.currentYardId ?? null, + fromValue: previousStatus, + toValue: WagonStatus.Maintenance, + reason: note?.trim() || null, + metadata: { trainCode: train.code }, + }); // Audit row: which train it came off and when. The wagon does not change // yard here, so from/to are the same — the ledger is the wagon's history // surface, and a maintenance detach has to be in it. @@ -1166,6 +1215,22 @@ export class TrainBuilderService { for (let i = 0; i < dto.wagonIds.length; i++) { await manager.getRepository(Wagon).update(dto.wagonIds[i], { sequenceNumber: i + 1 }); } + const previousSeq = new Map(wagons.map((w) => [w.id, w])); + await this.wagonHistory.record( + manager, + dto.wagonIds + .map((wid, i) => ({ wagon: previousSeq.get(wid), to: i + 1 })) + .filter((x) => x.wagon && x.wagon.sequenceNumber !== x.to) + .map(({ wagon, to }) => ({ + wagonId: wagon!.id, + wagonNumber: wagon!.wagonNumber, + type: WagonEventType.SequenceChanged, + trainId: train.id, + fromValue: wagon!.sequenceNumber, + toValue: to, + reason: 'Consist reordered', + })), + ); // Propagate the new order to every live (DRAFT/SCHEDULED) schedule of // this train: slots pinned to a reordered wagon adopt the wagon's new @@ -1287,6 +1352,10 @@ export class TrainBuilderService { 'Train has active schedules; cancel them before disbanding the train', ); } + const consist = await manager.getRepository(Wagon).find({ + where: { trainId: train.id }, + select: { id: true, wagonNumber: true, currentYardId: true, sequenceNumber: true, status: true }, + }); await manager .getRepository(Wagon) .update( @@ -1299,6 +1368,19 @@ export class TrainBuilderService { exportTrainNumber: null, }, ); + await this.wagonHistory.record( + manager, + consist.map((w) => ({ + wagonId: w.id, + wagonNumber: w.wagonNumber, + type: WagonEventType.TrainDisbanded, + trainId: train.id, + fromYardId: w.currentYardId ?? null, + fromValue: w.sequenceNumber, + reason: `Train ${train.code} disbanded`, + metadata: { status: { from: w.status, to: WagonStatus.Available } }, + })), + ); await manager.getRepository(TrainLocomotive).delete({ trainId: train.id }); await manager.getRepository(Train).remove(train); }); @@ -1432,6 +1514,25 @@ export class TrainBuilderService { ), ); + // COUPLED rows are written by attachWagons (build + assign); the detach + // side is logged here, where the reason and the live schedule are known. + await this.wagonHistory.record( + manager, + changes + .filter((c) => c.action === 'REMOVE') + .map((c) => ({ + wagonId: c.wagonId, + wagonNumber: c.wagonNumber, + type: WagonEventType.UncoupledFromTrain, + occurredAt: now, + actorUserId: userId, + trainId, + trainScheduleId: schedule?.id ?? null, + fromYardId: yardId, + reason: reason?.trim() || null, + })), + ); + if (!schedule) return null; await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount }); @@ -1543,6 +1644,7 @@ export class TrainBuilderService { train: Train, wagonIds: string[], startCount: number, + userId: string | null = null, ): Promise { const uniqueIds = [...new Set(wagonIds)]; const wagonRepo = manager.getRepository(Wagon); @@ -1572,6 +1674,7 @@ export class TrainBuilderService { await this.assertConsistLengthWithinLimit(manager, train, toAttach); let sequence = startCount; + const events: WagonEventInput[] = []; for (const wagon of toAttach) { sequence += 1; await wagonRepo.update(wagon.id, { @@ -1583,7 +1686,23 @@ export class TrainBuilderService { importTrainNumber: train.importTrainNumber, exportTrainNumber: train.exportTrainNumber, }); + events.push({ + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.CoupledToTrain, + actorUserId: userId, + trainId: train.id, + fromYardId: wagon.currentYardId ?? null, + toValue: sequence, + metadata: { + trainCode: train.code, + status: { from: wagon.status, to: WagonStatus.Assigned }, + importTrainNumber: train.importTrainNumber ?? null, + exportTrainNumber: train.exportTrainNumber ?? null, + }, + }); } + await this.wagonHistory.record(manager, events); return toAttach; } diff --git a/apps/edr-freight-api/src/modules/wagon-history/dto/wagon-history-query.dto.ts b/apps/edr-freight-api/src/modules/wagon-history/dto/wagon-history-query.dto.ts new file mode 100644 index 000000000..3ad9748d4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-history/dto/wagon-history-query.dto.ts @@ -0,0 +1,47 @@ +import { WagonEventCategory, WagonEventType } from '@edr/types'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { IsArray, IsDateString, IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class WagonHistoryQueryDto { + @ApiPropertyOptional({ enum: WagonEventCategory, description: 'Only events of this category' }) + @IsOptional() + @IsEnum(WagonEventCategory) + category?: WagonEventCategory; + + @ApiPropertyOptional({ + enum: WagonEventType, + isArray: true, + description: 'Only these event types (repeat the param or comma-separate)', + }) + @IsOptional() + @Transform(({ value }) => + Array.isArray(value) ? value : String(value).split(',').map((v) => v.trim()).filter(Boolean), + ) + @IsArray() + @IsEnum(WagonEventType, { each: true }) + types?: WagonEventType[]; + + @ApiPropertyOptional({ description: 'ISO timestamp — events at or after this moment' }) + @IsOptional() + @IsDateString() + from?: string; + + @ApiPropertyOptional({ description: 'ISO timestamp — events at or before this moment' }) + @IsOptional() + @IsDateString() + to?: string; + + @ApiPropertyOptional({ description: 'Opaque `nextCursor` from the previous page' }) + @IsOptional() + @IsString() + cursor?: string; + + @ApiPropertyOptional({ default: 50, minimum: 1, maximum: 200 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(200) + limit?: number; +} diff --git a/apps/edr-freight-api/src/modules/wagon-history/wagon-event.entity.ts b/apps/edr-freight-api/src/modules/wagon-history/wagon-event.entity.ts new file mode 100644 index 000000000..c0dedf760 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-history/wagon-event.entity.ts @@ -0,0 +1,74 @@ +import { WagonEventCategory, WagonEventType } from '@edr/types'; +import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; + +/** + * Append-only history of everything that happens to a wagon — one row per + * wagon per transition, written inside the same transaction as the change. + * Plain id columns, no foreign keys and no soft delete on purpose: the history + * must outlive the wagon, train, schedule or booking it refers to, exactly like + * `audit_logs` and `schedule_wagon_adjustment_logs`. Rows are never updated. + * + * Read path: `(wagon_id, occurred_at DESC, id DESC)` keyset pagination — one + * index range scan per page regardless of how long the wagon has been in + * service. Labels (yard, train, schedule, booking, actor) are joined at read + * time on primary keys, so the write path stays a single INSERT. + */ +@Entity({ schema: 'freight', name: 'wagon_events' }) +@Index('idx_wagon_events_wagon_time', ['wagonId', 'occurredAt', 'id']) +@Index('idx_wagon_events_wagon_cat_time', ['wagonId', 'category', 'occurredAt', 'id']) +export class WagonEvent { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'wagon_id', type: 'uuid' }) + wagonId!: string; + + /** Snapshot so the row still reads after the wagon is purged or renumbered. */ + @Column({ name: 'wagon_number', type: 'varchar', nullable: true }) + wagonNumber?: string | null; + + @Column({ name: 'event_type', type: 'varchar', length: 40 }) + type!: WagonEventType; + + /** Derived from `type` at write time; stored so the category filter hits the index. */ + @Column({ name: 'category', type: 'varchar', length: 20 }) + category!: WagonEventCategory; + + @Column({ name: 'occurred_at', type: 'timestamptz' }) + occurredAt!: Date; + + @Column({ name: 'actor_user_id', type: 'uuid', nullable: true }) + actorUserId?: string | null; + + @Column({ name: 'from_yard_id', type: 'uuid', nullable: true }) + fromYardId?: string | null; + + @Column({ name: 'to_yard_id', type: 'uuid', nullable: true }) + toYardId?: string | null; + + @Column({ name: 'train_id', type: 'uuid', nullable: true }) + trainId?: string | null; + + @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true }) + trainScheduleId?: string | null; + + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId?: string | null; + + /** Previous value of whatever the event changed (status, sequence, train code…). */ + @Column({ name: 'from_value', type: 'varchar', length: 120, nullable: true }) + fromValue?: string | null; + + @Column({ name: 'to_value', type: 'varchar', length: 120, nullable: true }) + toValue?: string | null; + + /** Staff-entered reason / note, when the action carried one. */ + @Column({ name: 'reason', type: 'text', nullable: true }) + reason?: string | null; + + @Column({ name: 'metadata', type: 'jsonb', nullable: true }) + metadata?: Record | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; +} diff --git a/apps/edr-freight-api/src/modules/wagon-history/wagon-history.module.ts b/apps/edr-freight-api/src/modules/wagon-history/wagon-history.module.ts new file mode 100644 index 000000000..d049578d3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-history/wagon-history.module.ts @@ -0,0 +1,16 @@ +import { Global, Module } from '@nestjs/common'; + +import { WagonHistoryService } from './wagon-history.service'; + +/** + * Global, dependency-free (only the DataSource): every service that writes a + * wagon row — wagons desk, train builder, scheduling, booking journey, + * containers, cancellations — records history through WagonHistoryService + * without adding a module edge, the same pattern as FleetHistoryModule. + */ +@Global() +@Module({ + providers: [WagonHistoryService], + exports: [WagonHistoryService], +}) +export class WagonHistoryModule {} diff --git a/apps/edr-freight-api/src/modules/wagon-history/wagon-history.service.spec.ts b/apps/edr-freight-api/src/modules/wagon-history/wagon-history.service.spec.ts new file mode 100644 index 000000000..0855d7c6a --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-history/wagon-history.service.spec.ts @@ -0,0 +1,135 @@ +import { BadRequestException } from '@nestjs/common'; +import { WagonEventCategory, WagonEventType } from '@edr/types'; + +import { WagonHistoryService } from './wagon-history.service'; + +/** Captures the INSERT query-builder chain and the raw list query. */ +function makeDataSource() { + const execute = jest.fn().mockResolvedValue(undefined); + const values = jest.fn(); + const chain = { insert: jest.fn(), into: jest.fn(), values, updateEntity: jest.fn(), execute }; + chain.insert.mockReturnValue(chain); + chain.into.mockReturnValue(chain); + values.mockReturnValue(chain); + chain.updateEntity.mockReturnValue(chain); + const manager = { createQueryBuilder: jest.fn(() => chain) }; + const query = jest.fn().mockResolvedValue([]); + return { dataSource: { manager, query }, manager, values, execute, query }; +} + +describe('WagonHistoryService.record', () => { + it('writes a batch as one INSERT, deriving the category from the type', async () => { + const { dataSource, values, execute } = makeDataSource(); + const service = new WagonHistoryService(dataSource as never); + const at = new Date('2026-09-01T10:00:00Z'); + + await service.record(dataSource.manager as never, [ + { wagonId: 'w1', wagonNumber: 'W-1', type: WagonEventType.MovedManually, toYardId: 'y2', occurredAt: at }, + { wagonId: 'w2', type: WagonEventType.CargoLoaded, bookingId: 'b1', toValue: 12.5 }, + null, + ]); + + expect(execute).toHaveBeenCalledTimes(1); + const rows = values.mock.calls[0][0]; + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ + wagonId: 'w1', + wagonNumber: 'W-1', + type: WagonEventType.MovedManually, + category: WagonEventCategory.Yard, + toYardId: 'y2', + occurredAt: at, + actorUserId: null, + }); + expect(rows[1]).toMatchObject({ + wagonId: 'w2', + category: WagonEventCategory.Cargo, + bookingId: 'b1', + toValue: '12.5', + }); + expect(rows[1].occurredAt).toBeInstanceOf(Date); + }); + + it('skips empty input without touching the database', async () => { + const { dataSource, execute } = makeDataSource(); + const service = new WagonHistoryService(dataSource as never); + await service.record(dataSource.manager as never, []); + await service.record(null, null); + expect(execute).not.toHaveBeenCalled(); + }); + + it('propagates a failure inside a caller transaction but swallows it outside one', async () => { + const { dataSource, execute } = makeDataSource(); + execute.mockRejectedValue(new Error('db down')); + const service = new WagonHistoryService(dataSource as never); + const input = { wagonId: 'w1', type: WagonEventType.Registered }; + + await expect(service.record(dataSource.manager as never, input)).rejects.toThrow('db down'); + await expect(service.record(null, input)).resolves.toBeUndefined(); + }); +}); + +describe('WagonHistoryService.list', () => { + const A = '11111111-1111-4111-8111-111111111111'; + const B = '22222222-2222-4222-8222-222222222222'; + const C = '33333333-3333-4333-8333-333333333333'; + const row = (id: string, at: string) => ({ + id, + wagonId: 'w1', + wagonNumber: 'W-1', + type: WagonEventType.PassedCheckpoint, + category: WagonEventCategory.Yard, + occurredAt: new Date(at), + actorUserId: null, + actorName: null, + fromYardId: 'y1', + fromYardLabel: 'Origin', + toYardId: 'y2', + toYardLabel: 'Stop', + trainId: null, + trainCode: null, + trainScheduleId: 's1', + scheduleLabel: 'V-100', + bookingId: null, + bookingReference: null, + fromValue: null, + toValue: null, + reason: null, + metadata: null, + }); + + it('returns a page with a cursor when more rows exist, and decodes that cursor on the next call', async () => { + const { dataSource, query } = makeDataSource(); + const service = new WagonHistoryService(dataSource as never); + query.mockResolvedValueOnce([ + row(A, '2026-09-01T10:00:00Z'), + row(B, '2026-09-01T09:00:00Z'), + row(C, '2026-09-01T08:00:00Z'), // the +1 probe row + ]); + + const first = await service.list('w1', { limit: 2, category: WagonEventCategory.Yard }); + expect(first.items.map((i) => i.id)).toEqual([A, B]); + expect(first.items[0].occurredAt).toBe('2026-09-01T10:00:00.000Z'); + expect(first.nextCursor).toEqual(expect.any(String)); + const [sql, params] = query.mock.calls[0]; + expect(sql).toContain('e.wagon_id = $1'); + expect(sql).toContain('e.category = $2'); + expect(sql).toContain('LIMIT 3'); + expect(params).toEqual(['w1', WagonEventCategory.Yard]); + + query.mockResolvedValueOnce([row(C, '2026-09-01T08:00:00Z')]); + const second = await service.list('w1', { limit: 2, cursor: first.nextCursor! }); + expect(second.items.map((i) => i.id)).toEqual([C]); + expect(second.nextCursor).toBeNull(); + const [sql2, params2] = query.mock.calls[1]; + expect(sql2).toContain('(e.occurred_at, e.id) < ($2, $3::uuid)'); + expect(params2[1]).toEqual(new Date('2026-09-01T09:00:00Z')); + expect(params2[2]).toBe(B); + }); + + it('rejects a malformed cursor', async () => { + const { dataSource } = makeDataSource(); + const service = new WagonHistoryService(dataSource as never); + await expect(service.list('w1', { cursor: 'not-a-cursor' })).rejects.toThrow(BadRequestException); + }); +}); diff --git a/apps/edr-freight-api/src/modules/wagon-history/wagon-history.service.ts b/apps/edr-freight-api/src/modules/wagon-history/wagon-history.service.ts new file mode 100644 index 000000000..982e927ce --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-history/wagon-history.service.ts @@ -0,0 +1,195 @@ +import { + WAGON_EVENT_CATEGORY, + WagonEventCategory, + WagonEventType, + WagonHistoryEvent, + WagonHistoryPage, +} from '@edr/types'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource, EntityManager } from 'typeorm'; +import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; + +import { WagonHistoryQueryDto } from './dto/wagon-history-query.dto'; +import { WagonEvent } from './wagon-event.entity'; + +/** One transition to append. Everything but the wagon and the type is optional context. */ +export interface WagonEventInput { + wagonId: string; + /** Snapshot for the row; pass it when the caller already holds the wagon (no lookup is made). */ + wagonNumber?: string | null; + type: WagonEventType; + /** Defaults to now. Pass the business timestamp when the caller has one. */ + occurredAt?: Date | null; + actorUserId?: string | null; + fromYardId?: string | null; + toYardId?: string | null; + trainId?: string | null; + trainScheduleId?: string | null; + bookingId?: string | null; + fromValue?: string | number | null; + toValue?: string | number | null; + reason?: string | null; + metadata?: Record | null; +} + +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 200; + +/** + * The single write and read path for `freight.wagon_events`. + * + * Writes: {@link record} takes the caller's EntityManager so the history row + * commits (or rolls back) with the business change — a wagon can never end up + * moved without its history row or vice versa. A batch is one INSERT. + * + * Reads: {@link list} is keyset-paginated on `(occurred_at, id)` under the + * per-wagon index, so page N costs the same as page 1; labels come from + * primary-key LEFT JOINs on the page only. + */ +@Injectable() +export class WagonHistoryService { + private readonly logger = new Logger(WagonHistoryService.name); + + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + + /** + * Append one or more events. Inside a transaction (manager given) a failure + * propagates — Postgres has already aborted the transaction at that point, + * so swallowing it would only hide the rollback. Outside a transaction the + * write is best-effort: logged, never thrown, so history can't break the + * operation that produced it. + */ + async record( + manager: EntityManager | null | undefined, + input: WagonEventInput | null | Array, + ): Promise { + const inputs = (Array.isArray(input) ? input : [input]).filter( + (i): i is WagonEventInput => Boolean(i?.wagonId), + ); + if (!inputs.length) return; + const now = new Date(); + const rows = inputs.map((i) => ({ + wagonId: i.wagonId, + wagonNumber: i.wagonNumber ?? null, + type: i.type, + category: WAGON_EVENT_CATEGORY[i.type] ?? WagonEventCategory.Lifecycle, + occurredAt: i.occurredAt ?? now, + actorUserId: i.actorUserId ?? null, + fromYardId: i.fromYardId ?? null, + toYardId: i.toYardId ?? null, + trainId: i.trainId ?? null, + trainScheduleId: i.trainScheduleId ?? null, + bookingId: i.bookingId ?? null, + fromValue: i.fromValue == null ? null : String(i.fromValue).slice(0, 120), + toValue: i.toValue == null ? null : String(i.toValue).slice(0, 120), + reason: i.reason?.trim() ? i.reason.trim() : null, + metadata: i.metadata ?? null, + })); + const mg = manager ?? this.dataSource.manager; + const write = () => + mg + .createQueryBuilder() + .insert() + .into(WagonEvent) + .values(rows as unknown as QueryDeepPartialEntity[]) + .updateEntity(false) + .execute(); + if (manager) { + await write(); + return; + } + try { + await write(); + } catch (err) { + this.logger.error( + `Failed to record ${rows.length} wagon event(s) (${rows[0].type}): ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + + /** One wagon's timeline, newest first, with labels resolved. */ + async list(wagonId: string, query: WagonHistoryQueryDto = {}): Promise { + const limit = Math.min(Math.max(query.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT); + const params: unknown[] = [wagonId]; + const where: string[] = ['e.wagon_id = $1']; + const push = (value: unknown) => { + params.push(value); + return `$${params.length}`; + }; + if (query.category) where.push(`e.category = ${push(query.category)}`); + if (query.types?.length) where.push(`e.event_type = ANY(${push(query.types)}::text[])`); + if (query.from) where.push(`e.occurred_at >= ${push(new Date(query.from))}`); + if (query.to) where.push(`e.occurred_at <= ${push(new Date(query.to))}`); + const cursor = decodeCursor(query.cursor); + if (cursor) { + // Row-value comparison walks the (wagon_id, occurred_at DESC, id DESC) index directly. + where.push(`(e.occurred_at, e.id) < (${push(cursor.occurredAt)}, ${push(cursor.id)}::uuid)`); + } + + const rows: Array = await this.dataSource.query( + `SELECT e.id, + e.wagon_id AS "wagonId", + e.wagon_number AS "wagonNumber", + e.event_type AS "type", + e.category, + e.occurred_at AS "occurredAt", + e.actor_user_id AS "actorUserId", + COALESCE(u.username, u.email) AS "actorName", + e.from_yard_id AS "fromYardId", + fy.label AS "fromYardLabel", + e.to_yard_id AS "toYardId", + ty.label AS "toYardLabel", + e.train_id AS "trainId", + t.code AS "trainCode", + e.train_schedule_id AS "trainScheduleId", + COALESCE(s.voyage_number, s.train_number) AS "scheduleLabel", + e.booking_id AS "bookingId", + b.reference AS "bookingReference", + e.from_value AS "fromValue", + e.to_value AS "toValue", + e.reason, + e.metadata + FROM freight.wagon_events e + LEFT JOIN iam.users u ON u.id = e.actor_user_id + LEFT JOIN freight.yards fy ON fy.id = e.from_yard_id + LEFT JOIN freight.yards ty ON ty.id = e.to_yard_id + LEFT JOIN freight.trains t ON t.id = e.train_id + LEFT JOIN freight.train_schedules s ON s.id = e.train_schedule_id + LEFT JOIN freight.bookings b ON b.id = e.booking_id + WHERE ${where.join(' AND ')} + ORDER BY e.occurred_at DESC, e.id DESC + LIMIT ${limit + 1}`, + params, + ); + + const hasMore = rows.length > limit; + const page = hasMore ? rows.slice(0, limit) : rows; + const last = page[page.length - 1]; + return { + items: page.map((r) => ({ + ...r, + occurredAt: new Date(r.occurredAt).toISOString(), + })), + nextCursor: hasMore && last ? encodeCursor(new Date(last.occurredAt), last.id) : null, + }; + } +} + +function encodeCursor(occurredAt: Date, id: string): string { + return Buffer.from(`${occurredAt.toISOString()}|${id}`, 'utf8').toString('base64url'); +} + +function decodeCursor(cursor?: string): { occurredAt: Date; id: string } | null { + if (!cursor) return null; + const raw = Buffer.from(cursor, 'base64url').toString('utf8'); + const sep = raw.indexOf('|'); + const occurredAt = sep > 0 ? new Date(raw.slice(0, sep)) : new Date(NaN); + const id = sep > 0 ? raw.slice(sep + 1) : ''; + if (Number.isNaN(occurredAt.getTime()) || !/^[0-9a-f-]{36}$/i.test(id)) { + throw new BadRequestException('Invalid history cursor'); + } + return { occurredAt, id }; +} diff --git a/apps/edr-freight-api/src/modules/wagons/purge-guard.spec.ts b/apps/edr-freight-api/src/modules/wagons/purge-guard.spec.ts index ab86cea51..d86f65367 100644 --- a/apps/edr-freight-api/src/modules/wagons/purge-guard.spec.ts +++ b/apps/edr-freight-api/src/modules/wagons/purge-guard.spec.ts @@ -14,7 +14,12 @@ const makeService = (wagon: any, counts: [number, number, number], pinned = fals return []; }), }; - const svc = new WagonsService(wagonRepo as any, {} as any, dataSource as any); + const svc = new WagonsService( + wagonRepo as any, + {} as any, + dataSource as any, + { record: jest.fn() } as any, + ); return { svc, wagonRepo }; }; @@ -54,7 +59,12 @@ describe('WagonsService.purge', () => { it('404s an unknown wagon', async () => { const wagonRepo = { findOne: jest.fn().mockResolvedValue(null), remove: jest.fn() }; - const svc = new WagonsService(wagonRepo as any, {} as any, { query: jest.fn() } as any); + const svc = new WagonsService( + wagonRepo as any, + {} as any, + { query: jest.fn() } as any, + { record: jest.fn() } as any, + ); await expect(svc.purge('nope')).rejects.toThrow(NotFoundException); expect(wagonRepo.remove).not.toHaveBeenCalled(); }); diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts index e4492e5cb..89c2fd9f7 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts @@ -27,6 +27,8 @@ import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto'; import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto'; import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto'; import { WagonsService } from './wagons.service'; +import { WagonHistoryQueryDto } from '../wagon-history/dto/wagon-history-query.dto'; +import { WagonHistoryService } from '../wagon-history/wagon-history.service'; @ApiTags('wagons') // No class-level guard: reads (list, by-id, movements) are login-only reference @@ -34,13 +36,16 @@ import { WagonsService } from './wagons.service'; // fleet:view that drives the Fleet sidebar. Every mutation has its @FleetManage(). @Controller('wagons') export class WagonsController { - constructor(private readonly wagonsService: WagonsService) {} + constructor( + private readonly wagonsService: WagonsService, + private readonly wagonHistory: WagonHistoryService, + ) {} @Post() @FleetManage(FREIGHT_PERMS.wagons.create) @ApiOperation({ summary: 'Create a new wagon' }) - create(@Body() dto: CreateWagonDto) { - return this.wagonsService.create(dto); + create(@Body() dto: CreateWagonDto, @CurrentUser() user: TCurrentUser) { + return this.wagonsService.create(dto, user?.id); } @Get() @@ -68,11 +73,26 @@ export class WagonsController { return this.wagonsService.listMovements(id); } + @Get(':id/history') + @FleetView(FREIGHT_PERMS.wagons.view) + @ApiOperation({ + summary: + 'Unified wagon history — yard moves, coupling, schedule pins/dispatch, status flips, cargo, lifecycle — newest first, keyset-paginated (`cursor`)', + }) + history(@Param('id', ParseUUIDPipe) id: string, @Query() query: WagonHistoryQueryDto) { + // No existence check on purpose: a deleted or purged wagon keeps its history. + return this.wagonHistory.list(id, query); + } + @Patch(':id') @FleetManage(FREIGHT_PERMS.wagons.update) @ApiOperation({ summary: 'Update a wagon' }) - update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonDto) { - return this.wagonsService.update(id, dto); + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateWagonDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.wagonsService.update(id, dto, user?.id); } // Declared before @Delete(':id') so "permanent" is never captured as an id. @@ -86,29 +106,33 @@ export class WagonsController { summary: 'Permanently delete a wagon (irreversible; refused if it has movements, containers or train-set slots)', }) - purge(@Param('id', ParseUUIDPipe) id: string) { - return this.wagonsService.purge(id); + purge(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) { + return this.wagonsService.purge(id, user?.id); } @Delete(':id') @FleetManage(FREIGHT_PERMS.wagons.delete) @ApiOperation({ summary: 'Delete a wagon' }) - remove(@Param('id', ParseUUIDPipe) id: string) { - return this.wagonsService.remove(id); + remove(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) { + return this.wagonsService.remove(id, user?.id); } @Post(':id/assign-train') @FleetManage(FREIGHT_PERMS.trains.assignWagons) @ApiOperation({ summary: 'Assign wagon to a train' }) - assignToTrain(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignWagonToTrainDto) { - return this.wagonsService.assignToTrain(id, dto); + assignToTrain( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AssignWagonToTrainDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.wagonsService.assignToTrain(id, dto, user?.id); } @Post(':id/unassign-train') @FleetManage(FREIGHT_PERMS.trains.assignWagons) @ApiOperation({ summary: 'Unassign wagon from train' }) - unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) { - return this.wagonsService.unassignFromTrain(id); + unassignFromTrain(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) { + return this.wagonsService.unassignFromTrain(id, user?.id); } @Post('bulk-transfer') diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index 1ae1093f2..f15722b67 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -1,4 +1,10 @@ -import { Freight, PaginatedResponse, WagonMovementKind, WagonStatus } from '@edr/types'; +import { + Freight, + PaginatedResponse, + WagonEventType, + WagonMovementKind, + WagonStatus, +} from '@edr/types'; import { BadRequestException, Injectable, @@ -19,6 +25,16 @@ import { WagonStatusLog } from './entities/wagon-status-log.entity'; import { WagonMovement } from './entities/wagon-movement.entity'; import { Train } from '../trains/entities/train.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; +import { WagonEventInput, WagonHistoryService } from '../wagon-history/wagon-history.service'; + +/** Wagon columns whose manual edits are diffed into a DETAILS_UPDATED history row. */ +const TRACKED_DETAIL_FIELDS = [ + 'wagonNumber', + 'wagonTypeId', + 'exportTrainNumber', + 'importTrainNumber', + 'notes', +] as const; @Injectable() export class WagonsService { @@ -28,9 +44,10 @@ export class WagonsService { @InjectRepository(Train) private readonly trainRepo: Repository, private readonly dataSource: DataSource, + private readonly wagonHistory: WagonHistoryService, ) {} - async create(dto: CreateWagonDto): Promise { + async create(dto: CreateWagonDto, userId?: string | null): Promise { const wagon = this.wagonRepo.create({ ...dto, status: dto.status ?? WagonStatus.Available, @@ -41,7 +58,22 @@ export class WagonsService { if (dto.currentYardId === undefined) wagon.currentYardId = null; if (dto.exportTrainNumber === undefined) wagon.exportTrainNumber = null; if (dto.importTrainNumber === undefined) wagon.importTrainNumber = null; - return this.wagonRepo.save(wagon); + const saved = await this.wagonRepo.save(wagon); + await this.wagonHistory.record(null, { + wagonId: saved.id, + wagonNumber: saved.wagonNumber, + type: WagonEventType.Registered, + actorUserId: userId ?? null, + toYardId: saved.currentYardId ?? null, + trainId: saved.trainId ?? null, + toValue: saved.status, + metadata: { + wagonTypeId: saved.wagonTypeId, + exportTrainNumber: saved.exportTrainNumber ?? null, + importTrainNumber: saved.importTrainNumber ?? null, + }, + }); + return saved; } /** Shared filter/sort builder behind `findAll` (array) and `findAllPaged` (envelope). */ @@ -210,6 +242,10 @@ export class WagonsService { } } const previousYardId = wagon.currentYardId ?? null; + const previousStatus = wagon.status; + const before = Object.fromEntries( + TRACKED_DETAIL_FIELDS.map((f) => [f, (wagon as unknown as Record)[f] ?? null]), + ); Object.assign(wagon, dto); // `findById` eager-loads `currentYard`; when the DTO changes the scalar FK // TypeORM otherwise re-derives `current_yard_id` from the STALE relation @@ -243,6 +279,47 @@ export class WagonsService { }), ); } + // History: one row per kind of change — a yard move, a status flip, and + // the remaining field edits as a single diff. + const events: WagonEventInput[] = []; + const changes: Record = {}; + for (const f of TRACKED_DETAIL_FIELDS) { + if (dto[f] === undefined) continue; + const to = (wagon as unknown as Record)[f] ?? null; + if (before[f] !== to) changes[f] = { from: before[f], to }; + } + if (Object.keys(changes).length) { + events.push({ + wagonId: id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.DetailsUpdated, + actorUserId: userId ?? null, + metadata: { changes }, + }); + } + if (dto.currentYardId !== undefined && dto.currentYardId !== previousYardId) { + events.push({ + wagonId: id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.MovedManually, + actorUserId: userId ?? null, + fromYardId: previousYardId, + toYardId: dto.currentYardId ?? null, + reason: 'Wagon record edited', + }); + } + if (dto.status !== undefined && dto.status !== previousStatus) { + events.push({ + wagonId: id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.StatusChanged, + actorUserId: userId ?? null, + fromValue: previousStatus, + toValue: dto.status, + reason: 'Wagon record edited', + }); + } + await this.wagonHistory.record(null, events); // Re-read with the relation so the response reflects the new yard label // instead of the stale relation object loaded before the assign. return this.findById(id); @@ -258,7 +335,7 @@ export class WagonsService { }); } - async remove(id: string): Promise { + async remove(id: string, userId?: string | null): Promise { const wagon = await this.findById(id); // A coupled wagon must be detached via train-builder before it can be // removed, so a built train never silently loses a wagon. @@ -275,6 +352,14 @@ export class WagonsService { // Soft delete (deleted_at) — hard-deleting would strand ledger/schedule // history that references this wagon. await this.wagonRepo.softRemove(wagon); + await this.wagonHistory.record(null, { + wagonId: id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.Deleted, + actorUserId: userId ?? null, + fromYardId: wagon.currentYardId ?? null, + fromValue: wagon.status, + }); } /** @@ -288,7 +373,7 @@ export class WagonsService { * * Soft-deleted wagons are purgeable, so `withDeleted` is used to find them. */ - async purge(id: string): Promise { + async purge(id: string, userId?: string | null): Promise { const wagon = await this.wagonRepo.findOne({ where: { id }, withDeleted: true, @@ -343,6 +428,16 @@ export class WagonsService { ); } + // Recorded BEFORE the row goes: wagon_events has no FK, so the history of + // a purged wagon survives under its id and number snapshot. + await this.wagonHistory.record(null, { + wagonId: id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.Purged, + actorUserId: userId ?? null, + fromYardId: wagon.currentYardId ?? null, + fromValue: wagon.status, + }); await this.wagonRepo.remove(wagon); } @@ -366,7 +461,11 @@ export class WagonsService { return rows.length > 0; } - async assignToTrain(wagonId: string, dto: AssignWagonToTrainDto): Promise { + async assignToTrain( + wagonId: string, + dto: AssignWagonToTrainDto, + userId?: string | null, + ): Promise { const wagon = await this.findById(wagonId); // Mirror train-builder attachWagons: only a truly free, available wagon // (any yard) can be coupled, and never onto a dispatched train. @@ -399,13 +498,25 @@ export class WagonsService { ); } + const previousStatus = wagon.status; wagon.trainId = train.id; wagon.sequenceNumber = nextSequence; wagon.status = WagonStatus.Assigned; - return this.wagonRepo.save(wagon); + const saved = await this.wagonRepo.save(wagon); + await this.wagonHistory.record(null, { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.CoupledToTrain, + actorUserId: userId ?? null, + trainId: train.id, + fromYardId: wagon.currentYardId ?? null, + toValue: nextSequence, + metadata: { status: { from: previousStatus, to: WagonStatus.Assigned }, trainCode: train.code }, + }); + return saved; } - async unassignFromTrain(wagonId: string): Promise { + async unassignFromTrain(wagonId: string, userId?: string | null): Promise { const wagon = await this.findById(wagonId); // A wagon pinned to a live schedule is still operationally committed even // if the fleet train is being edited — don't free it out from under it. @@ -414,10 +525,24 @@ export class WagonsService { `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be detached`, ); } + const previousTrainId = wagon.trainId; + const previousSequence = wagon.sequenceNumber; + const previousStatus = wagon.status; wagon.trainId = null; wagon.sequenceNumber = null; wagon.status = WagonStatus.Available; - return this.wagonRepo.save(wagon); + const saved = await this.wagonRepo.save(wagon); + await this.wagonHistory.record(null, { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.UncoupledFromTrain, + actorUserId: userId ?? null, + trainId: previousTrainId, + fromYardId: wagon.currentYardId ?? null, + fromValue: previousSequence, + metadata: { status: { from: previousStatus, to: WagonStatus.Available } }, + }); + return saved; } /** @@ -464,9 +589,20 @@ export class WagonsService { } let moved = 0; + const events: WagonEventInput[] = []; for (const wagon of wagons) { const previousYardId = wagon.currentYardId ?? null; if (previousYardId === toYardId) continue; + events.push({ + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.MovedManually, + actorUserId: userId ?? null, + fromYardId: previousYardId, + toYardId, + reason: opts?.transferRequestId ? 'Transfer request fulfilled' : 'Bulk transfer', + metadata: opts?.transferRequestId ? { transferRequestId: opts.transferRequestId } : null, + }); wagon.currentYardId = toYardId; // Drop the eager relation so the scalar FK wins on save (see `update`). wagon.currentYard = null; @@ -484,6 +620,7 @@ export class WagonsService { ); moved++; } + await this.wagonHistory.record(queryRunner.manager, events); await queryRunner.commitTransaction(); return { moved }; @@ -547,6 +684,18 @@ export class WagonsService { } await queryRunner.manager.save(Wagon, wagons); if (logs.length) await queryRunner.manager.save(WagonStatusLog, logs); + await this.wagonHistory.record( + queryRunner.manager, + logs.map((l) => ({ + wagonId: l.wagonId, + wagonNumber: wagons.find((w) => w.id === l.wagonId)?.wagonNumber ?? null, + type: WagonEventType.StatusChanged, + actorUserId: changedByUserId ?? null, + fromValue: l.fromStatus, + toValue: l.toStatus, + reason: dto.note ?? null, + })), + ); await queryRunner.commitTransaction(); return { updated: wagons.length }; diff --git a/apps/edr-freight-api/src/modules/warehouses/container-stack-placement.spec.ts b/apps/edr-freight-api/src/modules/warehouses/container-stack-placement.spec.ts new file mode 100644 index 000000000..6a9d2ccde --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/container-stack-placement.spec.ts @@ -0,0 +1,222 @@ +import { BadRequestException, ConflictException } from '@nestjs/common'; + +import { WarehousePlacementService } from './warehouse-placement.service'; +import { WarehouseZoneStacksService } from './warehouse-zone-stacks.service'; + +/** + * The physical rules a yard operator would recognise: nothing floats above an + * empty level, a slot holds one box, and the ids a client sends are only + * believed after the whole chain has been resolved server-side. + */ + +const CHAIN = { + slotId: 'slot-2', + slotStatus: 'AVAILABLE', + slotIsActive: true, + level: 2, + stackId: 'stack-1', + stackCode: 'ZA-001', + stackStatus: 'ACTIVE', + stackIsActive: true, + maxStackHeight: 3, + zoneId: 'zone-1', + zoneCode: 'L1-O-A-ZA', + zoneType: 'CONTAINER_ZONE', + zoneStatus: 'ACTIVE', + zoneIsActive: true, + yardId: 'yard-1', + yardCode: 'L1-O-A', + yardType: 'CONTAINER_YARD', + yardDirection: null, + yardStatus: 'ACTIVE', + yardIsActive: true, + warehouseId: 'wh-1', + warehouseCode: 'L1-OPEN', + warehouseStatus: 'ACTIVE', + warehouseIsActive: true, +}; + +/** A placement service whose slot chain and stack occupancy are dictated by the test. */ +function makePlacement(chain: Partial, occupiedLevels: number[], slotTakenBy: string | null = null) { + const service = Object.create(WarehousePlacementService.prototype) as Record; + service.resolveSlot = jest.fn().mockResolvedValue({ ...CHAIN, ...chain }); + service.occupiedLevels = jest.fn().mockResolvedValue(occupiedLevels); + service.em = () => ({ query: jest.fn().mockResolvedValue(slotTakenBy ? [{ id: slotTakenBy }] : []) }); + return service as unknown as WarehousePlacementService; +} + +const placementInput = { + slotId: 'slot-2', + warehouseId: 'wh-1', + yardId: 'yard-1', + zoneId: 'zone-1', + quantity: 1, +}; + +describe('WarehousePlacementService.assertStackable', () => { + const service = Object.create(WarehousePlacementService.prototype) as WarehousePlacementService; + + it('always allows the ground level', () => { + expect(() => service.assertStackable({ level: 1, stackCode: 'ZA-001' }, [])).not.toThrow(); + }); + + it('allows level 2 once level 1 is filled', () => { + expect(() => service.assertStackable({ level: 2, stackCode: 'ZA-001' }, [1])).not.toThrow(); + }); + + it('allows level 3 once levels 1 and 2 are filled', () => { + expect(() => service.assertStackable({ level: 3, stackCode: 'ZA-001' }, [1, 2])).not.toThrow(); + }); + + it('refuses level 2 over an empty ground level', () => { + expect(() => service.assertStackable({ level: 2, stackCode: 'ZA-001' }, [])).toThrow( + /level 2 cannot be filled while level\(s\) 1 are empty/, + ); + }); + + it('refuses level 3 when level 2 is empty', () => { + expect(() => service.assertStackable({ level: 3, stackCode: 'ZA-001' }, [1])).toThrow( + /level\(s\) 2 are empty/, + ); + }); +}); + +describe('WarehousePlacementService.validateSlotForInventory', () => { + it('accepts a consistent hierarchy with the level below filled', async () => { + const service = makePlacement({}, [1]); + await expect(service.validateSlotForInventory(placementInput)).resolves.toMatchObject({ + stackId: 'stack-1', + level: 2, + }); + }); + + it('refuses a slot belonging to another zone', async () => { + const service = makePlacement({ zoneId: 'other-zone' }, [1]); + await expect(service.validateSlotForInventory(placementInput)).rejects.toBeInstanceOf(BadRequestException); + }); + + it('refuses a zone whose yard is not the one given', async () => { + const service = makePlacement({ yardId: 'other-yard' }, [1]); + await expect(service.validateSlotForInventory(placementInput)).rejects.toThrow(/does not belong|not the yard given/); + }); + + it('refuses a yard whose warehouse is not the one given', async () => { + const service = makePlacement({ warehouseId: 'other-wh' }, [1]); + await expect(service.validateSlotForInventory(placementInput)).rejects.toThrow(/not the warehouse given/); + }); + + it('refuses an inactive stack', async () => { + const service = makePlacement({ stackStatus: 'INACTIVE', stackIsActive: false }, [1]); + await expect(service.validateSlotForInventory(placementInput)).rejects.toThrow(/Stack ZA-001 is not active/); + }); + + it('refuses a blocked slot', async () => { + const service = makePlacement({ slotStatus: 'BLOCKED' }, [1]); + await expect(service.validateSlotForInventory(placementInput)).rejects.toThrow(/is BLOCKED/); + }); + + it('accepts a slot reserved for the box now arriving', async () => { + const service = makePlacement({ slotStatus: 'RESERVED' }, [1]); + await expect(service.validateSlotForInventory(placementInput)).resolves.toMatchObject({ level: 2 }); + }); + + it('refuses a slot another container already stands in', async () => { + const service = makePlacement({}, [1], 'other-inventory'); + await expect(service.validateSlotForInventory(placementInput)).rejects.toBeInstanceOf(ConflictException); + }); + + it('refuses a level above the stack height', async () => { + const service = makePlacement({ level: 4, slotId: 'slot-4' }, [1, 2, 3]); + await expect( + service.validateSlotForInventory({ ...placementInput, slotId: 'slot-4' }), + ).rejects.toThrow(/above stack ZA-001's maximum height of 3/); + }); + + it('refuses a row that still covers several containers', async () => { + const service = makePlacement({}, [1]); + await expect(service.validateSlotForInventory({ ...placementInput, quantity: 5 })).rejects.toThrow( + /covers 5 containers/, + ); + }); + + it('skips container stacking rules for a bulk yard', async () => { + // Level 2 over an empty level 1 would be refused in a container yard; + // a bulk yard has no vertical semantics to enforce. + const service = makePlacement({ yardType: 'BULK_YARD' }, []); + await expect(service.validateSlotForInventory({ ...placementInput, quantity: 12 })).resolves.toMatchObject({ + yardType: 'BULK_YARD', + }); + }); +}); + +describe('WarehousePlacementService.getContainerAccessibility', () => { + function makeAccessibility(placed: unknown, blocking: unknown[]) { + const service = Object.create(WarehousePlacementService.prototype) as Record; + const query = jest + .fn() + .mockResolvedValueOnce(placed ? [placed] : []) + .mockResolvedValueOnce(blocking); + service.em = () => ({ query }); + return service as unknown as WarehousePlacementService; + } + + it('reports a ground container buried under two others', async () => { + const service = makeAccessibility( + { inventoryId: 'inv-1', level: 1, stackId: 'stack-1', stackCode: 'ZA-001' }, + [ + { inventoryId: 'inv-3', level: 3, status: 'STORED', containerNumber: 'CONT-003' }, + { inventoryId: 'inv-2', level: 2, status: 'STORED', containerNumber: 'CONT-002' }, + ], + ); + + await expect(service.getContainerAccessibility('inv-1')).resolves.toEqual({ + accessible: false, + inventoryId: 'inv-1', + stackCode: 'ZA-001', + level: 1, + blockingContainers: [ + { inventoryId: 'inv-3', level: 3, status: 'STORED', containerNumber: 'CONT-003' }, + { inventoryId: 'inv-2', level: 2, status: 'STORED', containerNumber: 'CONT-002' }, + ], + }); + }); + + it('reports the top container as reachable', async () => { + const service = makeAccessibility({ inventoryId: 'inv-3', level: 3, stackId: 'stack-1', stackCode: 'ZA-001' }, []); + await expect(service.getContainerAccessibility('inv-3')).resolves.toMatchObject({ accessible: true }); + }); + + it('treats an item with no slot as reachable', async () => { + const service = makeAccessibility({ inventoryId: 'inv-9', level: null, stackId: null, stackCode: null }, []); + await expect(service.getContainerAccessibility('inv-9')).resolves.toEqual({ + accessible: true, + inventoryId: 'inv-9', + stackCode: null, + level: null, + blockingContainers: [], + }); + }); +}); + +describe('WarehouseZoneStacksService guards', () => { + function makeStacksService(occupied: number[]) { + const service = Object.create(WarehouseZoneStacksService.prototype) as Record; + service.placement = { occupiedLevels: jest.fn().mockResolvedValue(occupied) }; + service.stacksRepository = { + findById: jest.fn().mockResolvedValue({ id: 'stack-1', code: 'ZA-001', zoneId: 'zone-1', slots: [] }), + }; + service.dataSource = { transaction: jest.fn() }; + return service as unknown as WarehouseZoneStacksService; + } + + it('refuses to delete a stack that still holds containers', async () => { + await expect(makeStacksService([1, 2]).remove('stack-1')).rejects.toThrow( + /still holds 2 container\(s\) at level\(s\) 1, 2/, + ); + }); + + it('deletes an empty stack', async () => { + const service = makeStacksService([]); + await expect(service.remove('stack-1')).resolves.toEqual({ id: 'stack-1', deleted: true }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/delete-warehouse-guard.spec.ts b/apps/edr-freight-api/src/modules/warehouses/delete-warehouse-guard.spec.ts new file mode 100644 index 000000000..c87f59886 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/delete-warehouse-guard.spec.ts @@ -0,0 +1,46 @@ +import { ConflictException, NotFoundException } from '@nestjs/common'; + +import { WarehousesService } from './warehouses.service'; + +/** + * Deleting a warehouse that still holds yards would orphan every zone and the + * inventory sitting in them, so remove() refuses instead of cascading. + */ +function makeService(warehouse: unknown) { + const warehousesRepository = { + findById: jest.fn().mockResolvedValue(warehouse), + softDelete: jest.fn().mockResolvedValue(undefined), + }; + + const service = Object.create(WarehousesService.prototype) as Record; + service.warehousesRepository = warehousesRepository; + + return { service: service as unknown as WarehousesService, warehousesRepository }; +} + +describe('WarehousesService.remove', () => { + it('soft-deletes a warehouse with no yards', async () => { + const { service, warehousesRepository } = makeService({ id: 'w1', code: 'GMP', yards: [] }); + + await expect(service.remove('w1')).resolves.toEqual({ id: 'w1', deleted: true }); + expect(warehousesRepository.softDelete).toHaveBeenCalledWith('w1'); + }); + + it('refuses while yards remain', async () => { + const { service, warehousesRepository } = makeService({ + id: 'w1', + code: 'GMP', + yards: [{ id: 'y1' }], + }); + + await expect(service.remove('w1')).rejects.toBeInstanceOf(ConflictException); + expect(warehousesRepository.softDelete).not.toHaveBeenCalled(); + }); + + it('404s on an unknown warehouse', async () => { + const { service, warehousesRepository } = makeService(null); + + await expect(service.remove('nope')).rejects.toBeInstanceOf(NotFoundException); + expect(warehousesRepository.softDelete).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/delete-yard-zone-guards.spec.ts b/apps/edr-freight-api/src/modules/warehouses/delete-yard-zone-guards.spec.ts new file mode 100644 index 000000000..6cd0001e3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/delete-yard-zone-guards.spec.ts @@ -0,0 +1,88 @@ +import { ConflictException, NotFoundException } from '@nestjs/common'; + +import { WarehouseYardsService } from './warehouse-yards.service'; +import { WarehouseZonesService } from './warehouse-zones.service'; + +/** + * Soft-deleting a parent would leave its children pointing at a row every + * joining query drops, so both removes refuse while children exist. + */ +function makeYardsService(yard: unknown) { + const yardsRepository = { + findById: jest.fn().mockResolvedValue(yard), + softDelete: jest.fn().mockResolvedValue(undefined), + }; + const service = Object.create(WarehouseYardsService.prototype) as Record; + service.yardsRepository = yardsRepository; + return { service: service as unknown as WarehouseYardsService, yardsRepository }; +} + +function makeZonesService(zone: unknown, heldInventory: number, configuredStacks = 0) { + const zonesRepository = { + findById: jest.fn().mockResolvedValue(zone), + softDelete: jest.fn().mockResolvedValue(undefined), + }; + const inventoryRepository = { + findAndCount: jest.fn().mockResolvedValue([[], heldInventory]), + }; + const service = Object.create(WarehouseZonesService.prototype) as Record; + service.zonesRepository = zonesRepository; + service.inventoryRepository = inventoryRepository; + service.dataSource = { query: jest.fn().mockResolvedValue([{ count: configuredStacks }]) }; + return { service: service as unknown as WarehouseZonesService, zonesRepository }; +} + +describe('WarehouseYardsService.remove', () => { + it('soft-deletes a yard with no zones', async () => { + const { service, yardsRepository } = makeYardsService({ id: 'y1', code: 'CY-A', zones: [] }); + + await expect(service.remove('y1')).resolves.toEqual({ id: 'y1', deleted: true }); + expect(yardsRepository.softDelete).toHaveBeenCalledWith('y1'); + }); + + it('refuses while zones remain', async () => { + const { service, yardsRepository } = makeYardsService({ + id: 'y1', + code: 'CY-A', + zones: [{ id: 'z1' }], + }); + + await expect(service.remove('y1')).rejects.toBeInstanceOf(ConflictException); + expect(yardsRepository.softDelete).not.toHaveBeenCalled(); + }); + + it('404s on an unknown yard', async () => { + const { service } = makeYardsService(null); + + await expect(service.remove('nope')).rejects.toBeInstanceOf(NotFoundException); + }); +}); + +describe('WarehouseZonesService.remove', () => { + it('soft-deletes an empty zone', async () => { + const { service, zonesRepository } = makeZonesService({ id: 'z1', code: 'ZA' }, 0); + + await expect(service.remove('z1')).resolves.toEqual({ id: 'z1', deleted: true }); + expect(zonesRepository.softDelete).toHaveBeenCalledWith('z1'); + }); + + it('refuses while inventory sits in it', async () => { + const { service, zonesRepository } = makeZonesService({ id: 'z1', code: 'ZA' }, 16); + + await expect(service.remove('z1')).rejects.toBeInstanceOf(ConflictException); + expect(zonesRepository.softDelete).not.toHaveBeenCalled(); + }); + + it('refuses while ground stacks are still configured in it', async () => { + const { service, zonesRepository } = makeZonesService({ id: 'z1', code: 'ZA' }, 0, 20); + + await expect(service.remove('z1')).rejects.toThrow(/still has 20 configured stack\(s\)/); + expect(zonesRepository.softDelete).not.toHaveBeenCalled(); + }); + + it('404s on an unknown zone', async () => { + const { service } = makeZonesService(null, 0); + + await expect(service.remove('nope')).rejects.toBeInstanceOf(NotFoundException); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts index 8dd0681ce..85417ea87 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts @@ -1,6 +1,19 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { ArrayNotEmpty, IsArray, IsBoolean, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator'; +import { + ArrayMaxSize, + ArrayNotEmpty, + ArrayUnique, + IsArray, + IsBoolean, + IsIn, + IsNumber, + IsOptional, + IsString, + IsUUID, + Matches, + Min, +} from 'class-validator'; import { ValidateNested } from 'class-validator'; export class TruckEntranceDto { @@ -187,6 +200,22 @@ export class BulkReceiveDto { @IsUUID('all', { each: true }) bookingIds!: string[]; + /** + * The physical containers delivered by this truck. Container exports are + * received one truck at a time: either one 40ft box or up to two 20ft boxes. + */ + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @IsArray() + @ArrayNotEmpty() + @ArrayMaxSize(2) + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container number must match ISO container format, e.g. ABCD1234567', + }) + containerNumbers?: string[]; + @ApiPropertyOptional({ type: TruckEntranceDto }) @IsOptional() @ValidateNested() diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts index a99ca4f46..3928b6118 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts @@ -1,7 +1,14 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Matches, MaxLength, Min } from 'class-validator'; -import { WAREHOUSE_STATUSES, WAREHOUSE_TYPES, WarehouseStatus, WarehouseType } from '../entities/warehouse.entity'; +import { + FREIGHT_TYPES, + FreightType, + WAREHOUSE_STATUSES, + WAREHOUSE_TYPES, + WarehouseStatus, + WarehouseType, +} from '../entities/warehouse.entity'; export class CreateWarehouseDto { @ApiProperty() @@ -19,6 +26,11 @@ export class CreateWarehouseDto { @IsEnum(WAREHOUSE_TYPES) type!: WarehouseType; + @ApiPropertyOptional({ enum: FREIGHT_TYPES, description: 'Omit for a warehouse that takes both.' }) + @IsOptional() + @IsEnum(FREIGHT_TYPES) + freightType?: FreightType; + @ApiPropertyOptional({ format: 'uuid' }) @IsOptional() @IsUUID() diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/move-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/move-inventory.dto.ts index 1aae7896f..c34c5fd61 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/move-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/move-inventory.dto.ts @@ -14,6 +14,14 @@ export class MoveInventoryDto { @IsUUID() zoneId!: string; + @ApiPropertyOptional({ + format: 'uuid', + description: 'Exact physical slot in the destination zone. Container yards only.', + }) + @IsOptional() + @IsUUID() + slotId?: string; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/placement.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/placement.dto.ts new file mode 100644 index 000000000..55c5a2d8f --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/placement.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional, IsUUID } from 'class-validator'; + +export class FindAvailableSlotDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + yardId!: string; + + @ApiPropertyOptional({ format: 'uuid', description: 'Narrow the search to one zone.' }) + @IsOptional() + @IsUUID() + zoneId?: string; + + @ApiPropertyOptional({ enum: ['IMPORT', 'EXPORT', 'BOTH'], description: 'Null/BOTH matches any yard direction.' }) + @IsOptional() + @IsIn(['IMPORT', 'EXPORT', 'BOTH']) + direction?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + cargoTypeId?: string; +} + +export class AssignSlotDto { + @ApiPropertyOptional({ + format: 'uuid', + description: 'Target slot. Omit to let the placement engine pick the lowest free level.', + }) + @IsOptional() + @IsUUID() + slotId?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/register-backlog.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/register-backlog.dto.ts new file mode 100644 index 000000000..0c8ecde4b --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/register-backlog.dto.ts @@ -0,0 +1,114 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsDateString, + IsNumber, + IsOptional, + IsString, + IsUUID, + MaxLength, + Min, + ValidateNested, +} from 'class-validator'; + +/** + * A loaded container that was already sitting in a yard before the system knew + * about it. It has no booking, so the owner is carried as a company reference + * or free text, and `arrivedAt` is the true historical arrival rather than now. + */ +export class RegisterBacklogContainerDto { + @ApiProperty({ description: 'ISO 6346 container number' }) + @IsString() + @MaxLength(20) + containerNumber!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + containerTypeId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + warehouseId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + yardId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + zoneId!: string; + + @ApiProperty({ description: 'True historical arrival date — drives nothing billable.' }) + @IsDateString() + arrivedAt!: string; + + @ApiPropertyOptional({ format: 'uuid', description: 'Registered customer, when the owner is one.' }) + @IsOptional() + @IsUUID() + companyId?: string; + + @ApiPropertyOptional({ description: 'Owner name — free text when the company is not a customer yet.' }) + @IsOptional() + @IsString() + @MaxLength(200) + companyName?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(100) + sealNumber?: string; + + @ApiPropertyOptional({ description: 'Net weight in the unit the warehouse records (tonnes).' }) + @IsOptional() + @IsNumber() + @Min(0) + weight?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + volume?: number; + + /** + * ponytail: defaults to 0 when unknown, which is the honest value for a box + * nobody weighed. `containers.max_gross_weight` is a ceiling in + * cargoes.service, so set real figures here before this box is ever used for + * a new cargo assignment. + */ + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + tareWeight?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + maxGrossWeight?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} + +export class BulkRegisterBacklogDto { + @ApiProperty({ type: [RegisterBacklogContainerDto] }) + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(1000) + @ValidateNested({ each: true }) + @Type(() => RegisterBacklogContainerDto) + containers!: RegisterBacklogContainerDto[]; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/store-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/store-inventory.dto.ts index 08b15d536..1bdf1630a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/store-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/store-inventory.dto.ts @@ -22,6 +22,15 @@ export class StoreInventoryDto { @IsUUID() zoneId?: string; + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Exact physical slot. Container yards only; omit to let the placement engine pick the lowest free level.', + }) + @IsOptional() + @IsUUID() + slotId?: string; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/warehouse-zone-stack.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/warehouse-zone-stack.dto.ts new file mode 100644 index 000000000..4006d6140 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/warehouse-zone-stack.dto.ts @@ -0,0 +1,120 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator'; + +import { + DEFAULT_MAX_STACK_HEIGHT, + WAREHOUSE_ZONE_STACK_STATUSES, + WarehouseZoneStackStatus, +} from '../entities/warehouse-zone-stack.entity'; +import { + WAREHOUSE_ZONE_SLOT_STATUSES, + WarehouseZoneSlotStatus, +} from '../entities/warehouse-zone-slot.entity'; + +/** Nobody stacks boxes this high; the cap is here to catch a typo'd 30. */ +const MAX_SUPPORTED_STACK_HEIGHT = 10; + +export class CreateWarehouseZoneStackDto { + @ApiPropertyOptional({ format: 'uuid', description: 'Optional — taken from the route param when omitted' }) + @IsOptional() + @IsUUID() + zoneId?: string; + + @ApiProperty({ example: 'ZA-001' }) + @IsString() + @MaxLength(40) + code!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(160) + name?: string; + + @ApiPropertyOptional({ description: 'Physical row label' }) + @IsOptional() + @IsString() + @MaxLength(20) + row?: string; + + @ApiPropertyOptional({ description: 'Physical bay label' }) + @IsOptional() + @IsString() + @MaxLength(20) + bay?: string; + + @ApiPropertyOptional({ description: 'Physical position label' }) + @IsOptional() + @IsString() + @MaxLength(20) + position?: string; + + @ApiPropertyOptional({ + default: DEFAULT_MAX_STACK_HEIGHT, + description: 'One slot is generated per level, 1 to this height.', + }) + @IsOptional() + @IsInt() + @Min(1) + @Max(MAX_SUPPORTED_STACK_HEIGHT) + maxStackHeight?: number; +} + +export class UpdateWarehouseZoneStackDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(40) + code?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(160) + name?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(20) + row?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(20) + bay?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(20) + position?: string; + + @ApiPropertyOptional({ description: 'Raising it adds slots; lowering it removes the empty top levels.' }) + @IsOptional() + @IsInt() + @Min(1) + @Max(MAX_SUPPORTED_STACK_HEIGHT) + maxStackHeight?: number; + + @ApiPropertyOptional({ enum: WAREHOUSE_ZONE_STACK_STATUSES }) + @IsOptional() + @IsEnum(WAREHOUSE_ZONE_STACK_STATUSES) + status?: WarehouseZoneStackStatus; +} + +export class UpdateWarehouseZoneSlotDto { + @ApiPropertyOptional({ + enum: WAREHOUSE_ZONE_SLOT_STATUSES, + description: 'Operator intent only. OCCUPIED is derived from inventory and cannot be set here.', + }) + @IsOptional() + @IsEnum(WAREHOUSE_ZONE_SLOT_STATUSES) + status?: WarehouseZoneSlotStatus; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts index b2c4ca3c4..61c810b7f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts @@ -7,6 +7,8 @@ import { Container } from '../../container-management/entities/container.entity' import { Warehouse } from './warehouse.entity'; import { WarehouseYard } from './warehouse-yard.entity'; import { WarehouseZone } from './warehouse-zone.entity'; +import { WarehouseZoneSlot } from './warehouse-zone-slot.entity'; +import { WarehouseZoneStack } from './warehouse-zone-stack.entity'; // Lifecycle. Supersedes the Batch 1 set // (ARRIVED_AT_WAREHOUSE / UNDER_INSPECTION / READY_FOR_LOADING) — migrated in place. @@ -50,6 +52,23 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record WarehouseZoneStack, { nullable: true }) + @JoinColumn({ name: 'stack_id' }) + stack?: WarehouseZoneStack | null; + + @Column({ name: 'slot_id', type: 'uuid', nullable: true }) + slotId?: string | null; + + @ManyToOne(() => WarehouseZoneSlot, { nullable: true }) + @JoinColumn({ name: 'slot_id' }) + slot?: WarehouseZoneSlot | null; + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) bookingId?: string | null; @@ -105,6 +145,22 @@ export class WarehouseInventory extends BaseEntity { @Column({ name: 'goods_id', type: 'uuid', nullable: true }) goodsId?: string | null; + /** + * Registered as backlog: the box was already in the yard before the system + * knew about it. `arrivedAt` is the true, backdated arrival, but no storage + * or demurrage accrues — see WarehouseFeeService.previewForInventory. + */ + @Column({ name: 'backlog_registration', type: 'boolean', default: false }) + backlogRegistration!: boolean; + + /** Owner of a row with no booking to inherit one from. */ + @Column({ name: 'company_id', type: 'uuid', nullable: true }) + companyId?: string | null; + + /** Owner as text — a company that is not a registered customer yet. */ + @Column({ name: 'company_name', type: 'varchar', length: 200, nullable: true }) + companyName?: string | null; + @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, default: 0 }) quantity!: number; diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-zone-slot.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-zone-slot.entity.ts new file mode 100644 index 000000000..d783ff300 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-zone-slot.entity.ts @@ -0,0 +1,39 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { WarehouseZoneStack } from './warehouse-zone-stack.entity'; + +/** + * Stored slot status is *operator intent* only. Occupancy is never written + * here: it is derived from `warehouse_inventory.slot_id` plus the row's + * lifecycle status, so the two can never drift apart and no exit path + * (load / dispatch / deliver) has to remember to free a slot. The computed + * OCCUPIED value is what the API returns — see `SLOT_EFFECTIVE_STATUSES`. + */ +export const WAREHOUSE_ZONE_SLOT_STATUSES = ['AVAILABLE', 'BLOCKED', 'RESERVED', 'INACTIVE'] as const; +export type WarehouseZoneSlotStatus = (typeof WAREHOUSE_ZONE_SLOT_STATUSES)[number]; + +export const SLOT_EFFECTIVE_STATUSES = [...WAREHOUSE_ZONE_SLOT_STATUSES, 'OCCUPIED'] as const; +export type SlotEffectiveStatus = (typeof SLOT_EFFECTIVE_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'warehouse_zone_slots' }) +@Index(['stackId']) +@Index(['status']) +export class WarehouseZoneSlot extends BaseEntity { + @Column({ name: 'stack_id', type: 'uuid' }) + stackId!: string; + + @ManyToOne(() => WarehouseZoneStack, (stack) => stack.slots, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'stack_id' }) + stack?: WarehouseZoneStack; + + /** 1 = on the ground. Capped by the parent stack's maxStackHeight. */ + @Column({ name: 'level', type: 'int' }) + level!: number; + + @Column({ name: 'status', type: 'varchar', length: 16, default: 'AVAILABLE' }) + status!: WarehouseZoneSlotStatus; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-zone-stack.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-zone-stack.entity.ts new file mode 100644 index 000000000..25add0f8e --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-zone-stack.entity.ts @@ -0,0 +1,60 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; + +import { WarehouseZone } from './warehouse-zone.entity'; +import { WarehouseZoneSlot } from './warehouse-zone-slot.entity'; + +export const WAREHOUSE_ZONE_STACK_STATUSES = ['ACTIVE', 'INACTIVE'] as const; +export type WarehouseZoneStackStatus = (typeof WAREHOUSE_ZONE_STACK_STATUSES)[number]; + +/** Default vertical height of a container stack — three boxes, EDR's reach-stacker limit. */ +export const DEFAULT_MAX_STACK_HEIGHT = 3; + +/** + * One ground footprint inside a zone: the patch of concrete a container is put + * down on, and the levels above it. The zone is where allocation stops; this is + * where a box physically sits. + * + * Generic on purpose — a bulk or general-cargo zone may divide itself into + * stacks too — but the vertical stacking rules only run for CONTAINER_YARD. + */ +@Entity({ schema: 'freight', name: 'warehouse_zone_stacks' }) +@Index(['zoneId']) +@Index(['status']) +export class WarehouseZoneStack extends BaseEntity { + @Column({ name: 'zone_id', type: 'uuid' }) + zoneId!: string; + + @ManyToOne(() => WarehouseZone, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'zone_id' }) + zone?: WarehouseZone; + + /** Unique within the zone, e.g. ZA-001. */ + @Column({ name: 'code', type: 'varchar', length: 40 }) + code!: string; + + @Column({ name: 'name', type: 'varchar', length: 160, nullable: true }) + name?: string | null; + + /** Free-form physical coordinates. Labels, not numbers — yards mix A/B/C with 1/2/3. */ + @Column({ name: 'row', type: 'varchar', length: 20, nullable: true }) + row?: string | null; + + @Column({ name: 'bay', type: 'varchar', length: 20, nullable: true }) + bay?: string | null; + + @Column({ name: 'position', type: 'varchar', length: 20, nullable: true }) + position?: string | null; + + @Column({ name: 'max_stack_height', type: 'int', default: DEFAULT_MAX_STACK_HEIGHT }) + maxStackHeight!: number; + + @Column({ name: 'status', type: 'varchar', length: 16, default: 'ACTIVE' }) + status!: WarehouseZoneStackStatus; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @OneToMany(() => WarehouseZoneSlot, (slot) => slot.stack) + slots?: WarehouseZoneSlot[]; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts index 267fcc8af..a67986ad1 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts @@ -1,12 +1,16 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +import { FREIGHT_TYPES, FreightType } from '../../bookings/entities/booking.entity'; import { Facility } from '../../facilities/entities/facility.entity'; import { WarehouseYard } from './warehouse-yard.entity'; export const WAREHOUSE_TYPES = ['OPEN_WAREHOUSE', 'CLOSED_WAREHOUSE'] as const; export type WarehouseType = (typeof WAREHOUSE_TYPES)[number]; +export { FREIGHT_TYPES }; +export type { FreightType }; + export const WAREHOUSE_STATUSES = ['ACTIVE', 'INACTIVE'] as const; export type WarehouseStatus = (typeof WAREHOUSE_STATUSES)[number]; @@ -25,6 +29,14 @@ export class Warehouse extends BaseEntity { @Column({ name: 'type', type: 'varchar', length: 32 }) type!: WarehouseType; + /** + * What the warehouse handles. Null means unrestricted — the pre-existing + * behaviour for every warehouse created before this field existed, so it + * never narrows an already-configured site. + */ + @Column({ name: 'freight_type', type: 'varchar', length: 16, nullable: true }) + freightType?: FreightType | null; + @Column({ name: 'station_id', type: 'uuid', nullable: true }) stationId?: string | null; diff --git a/apps/edr-freight-api/src/modules/warehouses/train-loading-window-gate.spec.ts b/apps/edr-freight-api/src/modules/warehouses/train-loading-window-gate.spec.ts new file mode 100644 index 000000000..fe9adb389 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/train-loading-window-gate.spec.ts @@ -0,0 +1,77 @@ +import { WarehouseInventoryService } from './warehouse-inventory.service'; +import type { TrainLoadableItemRow } from './warehouse-inventory.service'; + +/** + * Cargo may only go onto a wagon inside a STARTED loading window at its + * boarding yard — the same rule the train schedule's own Load button enforces + * (assertStationWorkStarted). The warehouse loading queues load through a + * different service, so the rule is mirrored here; without it the two surfaces + * disagree and the queue offers a Load the schedule would refuse. + * + * Only the DataSource is touched, so the instance is built off the prototype + * rather than stubbing every collaborator. + */ +const row = (over: Partial = {}): TrainLoadableItemRow => + ({ + id: 'inv-1', + bookingId: 'b-1', + bookingReference: 'BK-1', + customerName: 'Acme', + containerNumber: 'CN-1', + cargoType: 'General', + weight: 20, + grnNumber: 'GRN-1', + inspectionStatus: 'PASSED', + status: 'READY_FOR_LOADING', + wagonId: 'w-1', + wagonNumber: 'W-001', + sequenceNo: 1, + originYardId: 'yard-1', + originYardLabel: 'Modjo', + loadingWindowStarted: true, + loadable: true, + ...over, + }) as TrainLoadableItemRow; + +function makeService(items: TrainLoadableItemRow[]) { + const query = jest.fn().mockResolvedValue([ + { trainNumber: 'T-100', origin: 'Modjo', destination: 'Djibouti', departure: null }, + ]); + const load = jest.fn().mockResolvedValue(undefined); + const service = Object.create(WarehouseInventoryService.prototype) as Record; + service.dataSource = { query }; + service.load = load; + service.trainLoadableItems = jest.fn().mockResolvedValue(items); + return { service: service as unknown as WarehouseInventoryService, load }; +} + +describe('loadItemsOntoTrain() — station loading window gate', () => { + it('skips an item whose boarding yard has no started loading window', async () => { + const { service, load } = makeService([row({ loadingWindowStarted: false })]); + + const result = await service.loadItemsOntoTrain('sched-1', ['inv-1']); + + expect(load).not.toHaveBeenCalled(); + expect(result.loadedCount).toBe(0); + expect(result.skippedCount).toBe(1); + expect(result.results[0].reason).toContain('Start loading at Modjo first'); + }); + + it('loads once the window is started', async () => { + const { service, load } = makeService([row()]); + + const result = await service.loadItemsOntoTrain('sched-1', ['inv-1']); + + expect(load).toHaveBeenCalledTimes(1); + expect(result.loadedCount).toBe(1); + expect(result.skippedCount).toBe(0); + }); + + it('still reports the wagon blocker first — the window is not the only gate', async () => { + const { service } = makeService([row({ wagonId: null, loadingWindowStarted: false })]); + + const result = await service.loadItemsOntoTrain('sched-1', ['inv-1']); + + expect(result.results[0].reason).toContain('No wagon allocated'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index 576933c01..c59cd0617 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -12,6 +12,8 @@ import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; interface ItemAttributes { arrivedAt: Date | null; + /** Backlog-registered box: real arrival on the record, but never billed. */ + backlogRegistration: boolean; gateClearedAt: Date | null; releaseDate: Date | null; freightType: string | null; @@ -260,6 +262,7 @@ export class WarehouseFeeService { private async loadItem(inventoryId: string): Promise { const [row] = await this.dataSource.query( `SELECT inv.arrived_at AS "arrivedAt", + inv.backlog_registration AS "backlogRegistration", inv.gate_cleared_at AS "gateClearedAt", inv.release_date AS "releaseDate", inv.quantity AS "inventoryQuantity", @@ -777,6 +780,13 @@ export class WarehouseFeeService { /** Preview demurrage + storage fees for an inventory item using the most specific active rules. */ async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise { const item = await this.loadItem(inventoryId); + + // A backlog registration carries a backdated arrival so the record is + // honest about how long the box has sat, but it was never booked through + // EDR and is not billed for that history. No rule applies, so no preview — + // which also keeps it off the invoice, since invoicing reads this same list. + if (item.backlogRegistration) return []; + const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } }); const now = new Date(); @@ -894,6 +904,8 @@ export class WarehouseFeeService { trucks.map(async (t) => { const item: ItemAttributes = { arrivedAt: null, + // Truck detention is a per-truck charge, never a warehouse backlog row. + backlogRegistration: false, gateClearedAt: null, releaseDate: null, freightType: leg.freightType ?? null, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 19d3b4b7a..15a3f6c4a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -14,8 +14,13 @@ import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto'; import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto'; import { LoadInventoryDto } from './dto/load-inventory.dto'; import { MoveInventoryDto } from './dto/move-inventory.dto'; +import { AssignSlotDto, FindAvailableSlotDto } from './dto/placement.dto'; import { StoreInventoryDto } from './dto/store-inventory.dto'; import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto'; +import { + BulkRegisterBacklogDto, + RegisterBacklogContainerDto, +} from './dto/register-backlog.dto'; import { ApproveDeliveryDto } from './dto/approve-delivery.dto'; import { SetDoubleHandlingDto } from './dto/double-handling.dto'; import { ReleaseOrderDto } from './dto/release-order.dto'; @@ -143,6 +148,25 @@ export class WarehouseInventoryController { return this.inventoryService.eligibleBookings(dir); } + @Post('register-backlog') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.receive) + @ApiOperation({ + summary: 'Register one loaded container already in the yard but never entered in the system', + }) + registerBacklog(@Body() dto: RegisterBacklogContainerDto, @CurrentUser() user: TCurrentUser) { + dto.performedBy = actorLabel(user) ?? dto.performedBy; + return this.inventoryService.registerBacklogContainer(dto); + } + + @Post('register-backlog-bulk') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.receive) + @ApiOperation({ summary: 'Bulk-register loaded containers already in the yard (Excel backlog)' }) + registerBacklogBulk(@Body() dto: BulkRegisterBacklogDto, @CurrentUser() user: TCurrentUser) { + const performedBy = actorLabel(user); + dto.containers.forEach((c) => (c.performedBy = performedBy ?? c.performedBy)); + return this.inventoryService.bulkRegisterBacklogContainers(dto); + } + @Post('receive-bulk') @BookingStaff(FREIGHT_PERMS.warehouseInventory.receive) @ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' }) @@ -369,6 +393,50 @@ export class WarehouseInventoryController { return this.inventoryService.move(id, dto); } + @Post('placement/find-slot') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.move) + @ApiOperation({ + summary: 'Lowest free stack level for a container yard', + description: 'Read-only preview of where the placement engine would put the next container.', + }) + findAvailableSlot(@Body() dto: FindAvailableSlotDto) { + return this.inventoryService.findAvailableSlot(dto); + } + + @Post(':id/assign-slot') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.move) + @ApiOperation({ + summary: 'Place inventory at an exact stack level', + description: 'Omit slotId to take the lowest free level in the item\'s current zone.', + }) + assignSlot( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AssignSlotDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.inventoryService.assignSlot(id, dto.slotId, actorLabel(user)); + } + + @Post(':id/release-slot') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.move) + @ApiOperation({ + summary: 'Take inventory off its stack level', + description: 'Refused while other containers are stacked on top of it.', + }) + releaseSlot(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) { + return this.inventoryService.releaseSlot(id, actorLabel(user)); + } + + @Get(':id/accessibility') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) + @ApiOperation({ + summary: 'Can this container be lifted out', + description: 'Lists the containers stacked above it. Nothing is moved.', + }) + accessibility(@Param('id', ParseUUIDPipe) id: string) { + return this.inventoryService.getContainerAccessibility(id); + } + @Post(':id/store') @BookingStaff(FREIGHT_PERMS.warehouseInventory.move) @ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' }) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 126998bae..82231bacc 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -17,8 +17,10 @@ import { import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { generateGrnNumber } from '../../common/grn.util'; +import { assertTruckLoad } from '../../common/truck-load.util'; import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql'; import { Booking } from '../bookings/entities/booking.entity'; +import type { StationWorkLog } from '../train-schedules/entities/train-schedule.entity'; import { Cargo } from '../cargoes/entities/cargoes.entity'; import { Company } from '../companies/entities/company.entity'; import { Container } from '../container-management/entities/container.entity'; @@ -50,6 +52,10 @@ import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto'; import { LoadInventoryDto } from './dto/load-inventory.dto'; import { MoveInventoryDto } from './dto/move-inventory.dto'; import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto'; +import { + BulkRegisterBacklogDto, + RegisterBacklogContainerDto, +} from './dto/register-backlog.dto'; import { ReleaseOrderDto } from './dto/release-order.dto'; import { ReserveInventoryDto } from './dto/reserve-inventory.dto'; import { UnloadBookingDto } from './dto/unload-booking.dto'; @@ -70,6 +76,7 @@ import { Warehouse } from './entities/warehouse.entity'; import { SchedulingReadFacade } from './scheduling-read.facade'; import { WarehouseActivityLogService } from './warehouse-activity-log.service'; import { WarehouseInventoryRepository } from './warehouse-inventory.repository'; +import { WarehousePlacementService } from './warehouse-placement.service'; import { WarehouseLoadingRepository } from './warehouse-loading.repository'; import { WarehouseReleaseDocumentService } from './warehouse-release-document.service'; import { HandoverService } from './handover.service'; @@ -122,6 +129,7 @@ interface BookingSummaryRow { id: string; reference: string | null; status: string | null; + paymentStatus: string | null; customer: string | null; } @@ -269,12 +277,30 @@ export interface EligibleBookingRow { customerTruckType: string | null; customerTruckContainerNumber: string | null; customerTruckAssignedAt: string | null; + containerUnits: Array<{ + containerNumber: string; + containerSize: string | null; + weightTons: number; + received: boolean; + grnNumber: string | null; + }>; + receivedContainerCount: number; + remainingContainerCount: number; } export interface BulkReceiveResult { receivedCount: number; skippedCount: number; - results: { bookingId: string; status: string; inventoryId?: string; grnNumber?: string; reason?: string }[]; + results: { + bookingId: string; + status: string; + inventoryId?: string; + inventoryIds?: string[]; + grnNumber?: string; + receivedContainers?: number; + remainingContainers?: number; + reason?: string; + }[]; } @@ -314,6 +340,14 @@ export interface LoadableTrainRow { destination: string | null; status: string; departureTime: string | Date | null; + /** freight.yards.id the train departs from — the default boarding yard. */ + originStationId: string | null; + /** + * The schedule's per-yard loading/unloading time windows, exactly as the train + * schedule page stores them. The warehouse loading queues render the same + * Start/End controls off this, so both surfaces show one truth. + */ + stationWorkLogs: Record | null; /** Received/ready inventory not yet loaded onto this train. */ readyCount: number; /** Inventory already loaded onto this train. */ @@ -335,7 +369,12 @@ export interface TrainLoadableItemRow { wagonId: string | null; wagonNumber: string | null; sequenceNo: number | null; - /** True only when the item is READY_FOR_LOADING and has an allocated wagon. */ + /** The booking's boarding yard — the yard whose loading window gates this item. */ + originYardId: string | null; + originYardLabel: string | null; + /** True once "Start loading" was clicked for this item's boarding yard on this train. */ + loadingWindowStarted: boolean; + /** True only when the item is READY_FOR_LOADING, has an allocated wagon and GRN, and its yard's loading window is open. */ loadable: boolean; } @@ -412,6 +451,7 @@ export class WarehouseInventoryService { private readonly activityLog: WarehouseActivityLogService, private readonly scheduling: SchedulingReadFacade, private readonly allocation: WarehouseAllocationService, + private readonly placement: WarehousePlacementService, private readonly invoices: WarehouseInvoiceService, private readonly inspectionService: WarehouseInspectionService, private readonly releaseDocuments: WarehouseReleaseDocumentService, @@ -1333,14 +1373,19 @@ export class WarehouseInventoryService { return this.findById(saved.id); } - /** Auto-load all READY_FOR_LOADING inventory whose booking is PAID. Unpaid stay pending. */ + /** + * Auto-load all READY_FOR_LOADING inventory whose booking is paid (payment + * status PAID — the booking status is not consulted). Unpaid stay pending. + */ async autoLoadReady(): Promise { const ready = await this.inventoryRepository.findAll({ where: { status: 'READY_FOR_LOADING' } }); const result: AutoLoadResult = { loadedCount: 0, skippedCount: 0, results: [] }; for (const item of ready) { - const bookingStatus = item.bookingId ? await this.getBookingStatus(item.bookingId) : null; - if (bookingStatus !== 'PAID') { + const paymentStatus = item.bookingId + ? await this.getBookingPaymentStatus(item.bookingId) + : null; + if (paymentStatus !== 'PAID') { result.skippedCount += 1; result.results.push({ inventoryId: item.id, status: 'SKIPPED', reason: 'Booking not PAID' }); continue; @@ -1386,6 +1431,9 @@ export class WarehouseInventoryService { ${companyNotifyPhoneExpr('company')} AS "customerPhone", COALESCE(bcu.unit_numbers, bc.container_numbers) AS "containerNumber", bcu.seal_numbers AS "sealNumbers", + COALESCE(bcu.container_units, '[]'::json) AS "containerUnits", + COALESCE(bcu.received_count, 0)::int AS "receivedContainerCount", + COALESCE(bcu.remaining_count, 0)::int AS "remainingContainerCount", bc.container_quantity AS "containerQuantity", bc.container_packaging_type AS "containerPackagingType", -- service_types.includes_last_mile/first_mile are NOT read here: every @@ -1437,7 +1485,6 @@ export class WarehouseInventoryService { LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id - LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL LEFT JOIN LATERAL ( SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers, SUM(booking_container.quantity)::int AS container_quantity, @@ -1456,7 +1503,18 @@ export class WarehouseInventoryService { ) bc ON true LEFT JOIN LATERAL ( SELECT string_agg(NULLIF(unit.container_number, ''), ', ' ORDER BY unit.container_number) AS unit_numbers, - string_agg(DISTINCT NULLIF(unit.seal_number, ''), ', ') AS seal_numbers + string_agg(DISTINCT NULLIF(unit.seal_number, ''), ', ') AS seal_numbers, + COUNT(*) FILTER (WHERE unit.received_to_port)::int AS received_count, + COUNT(*) FILTER (WHERE NOT unit.received_to_port)::int AS remaining_count, + json_agg( + json_build_object( + 'containerNumber', unit.container_number, + 'containerSize', line.container_size, + 'weightTons', unit.vgm_tons, + 'received', unit.received_to_port, + 'grnNumber', unit.grn_number + ) ORDER BY unit.container_number + ) AS container_units FROM freight.booking_container_units unit JOIN freight.booking_container line ON line.id = unit.booking_container_id AND line.deleted_at IS NULL @@ -1473,7 +1531,14 @@ export class WarehouseInventoryService { LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id WHERE b.deleted_at IS NULL AND b.payment_status = 'PAID' - AND inv.id IS NULL + AND ( + (b.freight_type = 'CONTAINER' AND COALESCE(bcu.remaining_count, 0) > 0) + OR + (b.freight_type <> 'CONTAINER' AND NOT EXISTS ( + SELECT 1 FROM freight.warehouse_inventory inv + WHERE inv.booking_id = b.id AND inv.deleted_at IS NULL + )) + ) -- Direct truck-to-train cargo never comes to the warehouse, so never -- offer it for receipt. AND COALESCE(b.export_handover_mode, 'WAREHOUSE') <> 'DIRECT_TO_TRAIN' @@ -1503,6 +1568,7 @@ export class WarehouseInventoryService { grnNumber: string; direction?: string | null; warehouseId?: string | null; + bookingId?: string | null; }; booking: { companyId?: string | null; @@ -1629,9 +1695,6 @@ export class WarehouseInventoryService { } } - const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } }); - if (existing) { skip('Already received'); continue; } - const containerQuantity = Number(booking.containerQuantity ?? 0); if (booking.freightType === 'CONTAINER' && containerQuantity <= 0) { skip('Container booking has no container quantity'); @@ -1639,62 +1702,256 @@ export class WarehouseInventoryService { } const now = new Date(); - const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now, booking.customer); const truckEntrance = dto.truckEntrance ? this.mergeSystemTruckEntrance(dto.truckEntrance, booking) : undefined; + // Multi-truck self-haul is selected explicitly at the gate. The booking + // source contains comma-joined legacy summary fields, which must never + // replace the one physical truck the receiver selected. + if (truckEntrance && !booking.hasFirstMile && dto.truckEntrance) { + truckEntrance.truckPlateNumber = dto.truckEntrance.truckPlateNumber; + truckEntrance.driverName = dto.truckEntrance.driverName; + truckEntrance.driverPhone = dto.truckEntrance.driverPhone; + truckEntrance.truckType = dto.truckEntrance.truckType; + } if (dto.direction === 'EXPORT') { this.assertTruckEntrance(truckEntrance); } + + type ReceiveContainerUnit = { + containerNumber: string; + containerSize: string | null; + weightTons: string | number; + sealNumber: string | null; + bookingContainerId: string; + containerTypeId: string | null; + received: boolean; + }; + let selectedUnits: ReceiveContainerUnit[] = []; + let grnNumber = this.generateGrnNumber(dto.direction, bookingId, now, booking.customer); + + if (booking.freightType === 'CONTAINER') { + if (dto.bookingIds.length !== 1) { + throw new BadRequestException( + 'Receive one container booking per arriving truck so its containers and documents stay separate', + ); + } + const selectedNumbers = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + if (!selectedNumbers.length) { + throw new BadRequestException('Select the containers arriving on this truck'); + } + const allUnits: ReceiveContainerUnit[] = await manager.query( + `SELECT UPPER(bcu.container_number) AS "containerNumber", + bc.container_size AS "containerSize", + bcu.vgm_tons AS "weightTons", + bcu.seal_number AS "sealNumber", + bc.id AS "bookingContainerId", + bc.container_type_id AS "containerTypeId", + bcu.received_to_port AS received + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL + FOR UPDATE OF bcu`, + [bookingId], + ); + assertTruckLoad({ + containers: selectedNumbers, + bookingContainers: allUnits.map((unit) => unit.containerNumber), + sizes: allUnits + .filter((unit) => selectedNumbers.includes(unit.containerNumber)) + .map((unit) => unit.containerSize ?? ''), + }); + selectedUnits = allUnits.filter((unit) => selectedNumbers.includes(unit.containerNumber)); + if (selectedUnits.some((unit) => unit.received)) { + const repeated = selectedUnits.filter((unit) => unit.received).map((unit) => unit.containerNumber); + throw new BadRequestException(`Container(s) already received: ${repeated.join(', ')}`); + } + + // If this is a customer-assigned truck, it may only deliver the boxes + // assigned to that plate. Manual/unassigned arrivals retain the same + // physical capacity validation but have no assignment list to check. + if (truckEntrance?.truckPlateNumber) { + const assigned: Array<{ containerNumber: string }> = await manager.query( + `SELECT UPPER(ctc.container_number) AS "containerNumber" + FROM freight.customer_truck_assignments cta + JOIN freight.customer_truck_containers ctc + ON ctc.assignment_id = cta.id AND ctc.deleted_at IS NULL + WHERE cta.booking_id = $1 + AND UPPER(cta.plate_number) = UPPER($2) + AND cta.deleted_at IS NULL`, + [bookingId, truckEntrance.truckPlateNumber], + ); + if ( + assigned.length > 0 && + selectedNumbers.some( + (number) => !assigned.some((container) => container.containerNumber === number), + ) + ) { + throw new BadRequestException( + `Selected containers are not assigned to truck ${truckEntrance.truckPlateNumber}`, + ); + } + } + + const [{ batches }]: Array<{ batches: string }> = await manager.query( + `SELECT COUNT(DISTINCT inv.grn_number) AS batches + FROM freight.warehouse_inventory inv + WHERE inv.booking_id = $1 + AND inv.grn_number IS NOT NULL + AND inv.deleted_at IS NULL`, + [bookingId], + ); + grnNumber = `${grnNumber}-${String(Number(batches ?? 0) + 1).padStart(2, '0')}`; + if (truckEntrance) { + truckEntrance.assignedEquipmentNumber = selectedNumbers.join(', '); + truckEntrance.unitCount = selectedNumbers.length; + truckEntrance.netWeightKg = selectedUnits.reduce( + (total, unit) => total + Number(unit.weightTons || 0), + 0, + ); + } + } else { + const existing = await manager + .getRepository(WarehouseInventory) + .findOne({ where: { bookingId } }); + if (existing) { + skip('Already received'); + continue; + } + } + + const receivedBefore = + booking.freightType === 'CONTAINER' + ? Number( + ( + await manager.query( + `SELECT COUNT(*) AS count + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 + AND bcu.received_to_port = true + AND bcu.deleted_at IS NULL`, + [bookingId], + ) + )[0]?.count ?? 0, + ) + : 0; + const receivedAfter = receivedBefore + selectedUnits.length; + const remainingAfter = Math.max(0, containerQuantity - receivedAfter); const receiveNote = this.buildReceiveNote({ grnNumber, direction: dto.direction, - notes: `Bulk received (${dto.direction})`, + notes: + booking.freightType === 'CONTAINER' + ? `${selectedUnits.length} container(s) arrived: ${selectedUnits + .map((unit) => unit.containerNumber) + .join(', ')}. ${remainingAfter} container(s) left.` + : `Bulk received (${dto.direction})`, truckEntrance, }); // Validate capacity before saving - const weight = Number(booking.weight) || 0; - const containerCount = booking.freightType === 'CONTAINER' ? containerQuantity : 0; + const weight = + booking.freightType === 'CONTAINER' + ? selectedUnits.reduce((total, unit) => total + Number(unit.weightTons || 0), 0) + : Number(booking.weight) || 0; + const containerCount = booking.freightType === 'CONTAINER' ? selectedUnits.length : 0; this.assertCapacity('Warehouse', warehouse, weight, 0, containerCount); this.assertCapacity('Yard', yard, weight, 0, containerCount); this.assertCapacity('Zone', zone, weight, 0, containerCount); - const saved = await manager.getRepository(WarehouseInventory).save( - manager.getRepository(WarehouseInventory).create({ - warehouseId: dto.warehouseId, - yardId: dto.yardId, - zoneId: dto.zoneId, - bookingId, - quantity: booking.freightType === 'CONTAINER' ? containerQuantity : 1, - weight, - grnNumber, - status: 'RECEIVED', - arrivedAt: now, - notes: receiveNote, - }), - ); + const inventoryIds: string[] = []; + if (booking.freightType === 'CONTAINER') { + const containers = manager.getRepository(Container); + for (const unit of selectedUnits) { + let container = await containers.findOne({ + where: { containerNumber: unit.containerNumber }, + withDeleted: true, + }); + if (!container && !unit.containerTypeId) { + throw new BadRequestException( + `Container ${unit.containerNumber} has no container type and cannot be received`, + ); + } + if (!container) { + container = await containers.save( + containers.create({ + containerNumber: unit.containerNumber, + containerTypeId: unit.containerTypeId as string, + bookingContainerId: unit.bookingContainerId, + bookingId, + sealNumber: unit.sealNumber, + tareWeight: 0, + maxGrossWeight: Number(unit.weightTons || 0), + status: 'LOADED', + wagonId: null, + position: null, + wagonBookingAllocationId: null, + }), + ); + } else { + await containers.update(container.id, { + bookingId, + bookingContainerId: unit.bookingContainerId, + sealNumber: unit.sealNumber, + status: 'LOADED', + deletedAt: null, + }); + } + const saved = await manager.getRepository(WarehouseInventory).save( + manager.getRepository(WarehouseInventory).create({ + warehouseId: dto.warehouseId, + yardId: dto.yardId, + zoneId: dto.zoneId, + bookingId, + containerId: container.id, + quantity: 1, + weight: Number(unit.weightTons || 0), + grnNumber, + status: 'RECEIVED', + arrivedAt: now, + notes: receiveNote, + }), + ); + inventoryIds.push(saved.id); + } + await manager.query( + `UPDATE freight.booking_container_units bcu + SET received_to_port = true, + received_at = COALESCE(bcu.received_at, NOW()), + grn_number = $3, + updated_at = NOW() + FROM freight.booking_container bc + WHERE bc.id = bcu.booking_container_id + AND bc.booking_id = $1 + AND UPPER(bcu.container_number) = ANY($2::varchar[]) + AND bc.deleted_at IS NULL + AND bcu.deleted_at IS NULL`, + [bookingId, selectedUnits.map((unit) => unit.containerNumber), grnNumber], + ); + } else { + const saved = await manager.getRepository(WarehouseInventory).save( + manager.getRepository(WarehouseInventory).create({ + warehouseId: dto.warehouseId, + yardId: dto.yardId, + zoneId: dto.zoneId, + bookingId, + quantity: 1, + weight, + grnNumber, + status: 'RECEIVED', + arrivedAt: now, + notes: receiveNote, + }), + ); + inventoryIds.push(saved.id); + } // Update warehouse/yard/zone capacity counters await this.applyCapacityDelta(manager, dto, weight, 0, containerCount); - // Receiving the booking flags every container unit as received into the - // port (self-haul export: the delivering truck's goods are now in) so - // staff can raise the per-container GRN over what's received. - await manager.query( - `UPDATE freight.booking_container_units bcu - SET received_to_port = true, - received_at = COALESCE(bcu.received_at, NOW()), - updated_at = NOW() - FROM freight.booking_container bc - WHERE bc.id = bcu.booking_container_id - AND bc.booking_id = $1 - AND bc.deleted_at IS NULL - AND bcu.deleted_at IS NULL - AND bcu.received_to_port = false`, - [bookingId], - ); - // Export self-haul: this receive IS the truck's arrival — see // markCustomerTruckArrived / receive()'s single-booking mirror. if (dto.direction === 'EXPORT') { @@ -1704,7 +1961,7 @@ export class WarehouseInventoryService { await this.activityLog.record( { activityType: 'INVENTORY_RECEIVED', - inventoryId: saved.id, + inventoryId: inventoryIds[0], warehouseId: dto.warehouseId, description: truckEntrance?.truckPlateNumber ? `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}` @@ -1724,13 +1981,26 @@ export class WarehouseInventoryService { grnNumber, direction: dto.direction, warehouseId: dto.warehouseId, + bookingId, }, booking, bookingId, }); result.receivedCount += 1; - result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber }); + result.results.push({ + bookingId, + status: 'RECEIVED', + inventoryId: inventoryIds[0], + inventoryIds, + grnNumber, + ...(booking.freightType === 'CONTAINER' + ? { + receivedContainers: receivedAfter, + remainingContainers: remainingAfter, + } + : {}), + }); } }); @@ -1834,6 +2104,8 @@ export class WarehouseInventoryService { dy.country AS "destinationCountry", ts.status AS "status", ts.scheduled_departure_date AS "departureTime", + ts.origin_station_id AS "originStationId", + ts.station_work_logs AS "stationWorkLogs", (SELECT count(*) FROM sched_bookings sb JOIN freight.warehouse_inventory inv ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL @@ -1897,7 +2169,15 @@ export class WarehouseInventoryService { inv.status AS "status", wl.wagon_id AS "wagonId", wl.wagon_number AS "wagonNumber", - wl.sequence_no AS "sequenceNo" + wl.sequence_no AS "sequenceNo", + COALESCE(b.origin_yard_id, ts.origin_station_id) AS "originYardId", + COALESCE(oy.label, oy.code) AS "originYardLabel", + -- Same rule the train schedule's own Load button obeys + -- (assertStationWorkStarted): the yard's loading window must have + -- been started before its cargo may go on a wagon. + (ts.station_work_logs #>> ARRAY[ + COALESCE(b.origin_yard_id, ts.origin_station_id)::text, 'loading', 'startedAt' + ]) IS NOT NULL AS "loadingWindowStarted" FROM sched_bookings sb JOIN freight.train_schedules ts ON ts.id = sb.schedule_id JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL @@ -1905,6 +2185,7 @@ export class WarehouseInventoryService { LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id LEFT JOIN freight.containers ct ON ct.id = inv.container_id + LEFT JOIN freight.yards oy ON oy.id = COALESCE(b.origin_yard_id, ts.origin_station_id) LEFT JOIN LATERAL ( SELECT w.id AS wagon_id, w.wagon_number, tsw.sequence_no FROM freight.wagon_booking_allocations wba @@ -1927,9 +2208,14 @@ export class WarehouseInventoryService { ...r, // Export flow: received at the warehouse -> GRN -> loaded onto its wagon. // The row only exists once the goods were received, so requiring a GRN and - // an allocated wagon completes the chain. + // an allocated wagon completes the chain. The yard's loading window is the + // fourth link — the warehouse queue must not offer what the train + // schedule's own Load button would refuse. loadable: - r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId) && Boolean(r.grnNumber), + r.status === 'READY_FOR_LOADING' && + Boolean(r.wagonId) && + Boolean(r.grnNumber) && + r.loadingWindowStarted, })); } @@ -1986,6 +2272,11 @@ export class WarehouseInventoryService { // nothing rides a train without one. if (!item.grnNumber) { skip('No GRN — receive the goods and generate the GRN first'); continue; } if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; } + // Mirrors assertStationWorkStarted on the train-schedule load path. + if (!item.loadingWindowStarted) { + skip(`Start loading at ${item.originYardLabel ?? 'the boarding yard'} first — the loading time window has not been started`); + continue; + } try { await this.load(inventoryId, { @@ -2863,6 +3154,136 @@ export class WarehouseInventoryService { await this.lastMileService.acceptBooking(booking.reference); } + /** + * Register a loaded container that is already physically in a yard but was + * never entered in the system. Unlike receive(), there is no booking, no + * truck entrance to record (nobody remembers the driver of a box that has sat + * for months) and the arrival is backdated to when it actually turned up. + * + * The row is flagged `backlogRegistration`, which keeps the fee engine off it + * entirely — see WarehouseFeeService.previewForInventory. Capacity is still + * charged, because the box does occupy the yard. + */ + async registerBacklogContainer(dto: RegisterBacklogContainerDto): Promise { + const id = await this.dataSource.transaction((manager) => this.saveBacklogContainer(manager, dto)); + const saved = await this.inventoryRepository.findById(id); + if (!saved) throw new NotFoundException(`Inventory ${id} not found after registration`); + return saved; + } + + /** The write itself, so single and bulk share one transaction each. */ + private async saveBacklogContainer( + manager: EntityManager, + dto: RegisterBacklogContainerDto, + ): Promise { + const containerNumber = dto.containerNumber.trim().toUpperCase(); + const arrivedAt = new Date(dto.arrivedAt); + if (Number.isNaN(arrivedAt.getTime())) { + throw new BadRequestException(`Arrival date "${dto.arrivedAt}" is not a valid date`); + } + if (arrivedAt.getTime() > Date.now()) { + throw new BadRequestException('Arrival date cannot be in the future'); + } + + { + const { warehouse, yard, zone } = await this.validateLocation(manager, dto); + + const containerType = await manager.query( + `SELECT id FROM freight.container_types WHERE id = $1 AND deleted_at IS NULL`, + [dto.containerTypeId], + ); + if (containerType.length === 0) { + throw new NotFoundException(`Container type ${dto.containerTypeId} not found`); + } + + // container_number is UNIQUE — reuse the existing record rather than + // colliding, so a box seen before keeps one identity. + const containers = manager.getRepository(Container); + let container = await containers.findOne({ where: { containerNumber } }); + if (container) { + const alreadyHeld = await manager.getRepository(WarehouseInventory).findOne({ + where: { containerId: container.id, status: In(['RECEIVED', 'STORED', 'READY_FOR_PICKUP']) }, + }); + if (alreadyHeld) { + throw new BadRequestException( + `Container ${containerNumber} is already in the warehouse (status ${alreadyHeld.status})`, + ); + } + } else { + container = await containers.save( + containers.create({ + containerNumber, + containerTypeId: dto.containerTypeId, + sealNumber: dto.sealNumber?.trim() || null, + tareWeight: dto.tareWeight ?? 0, + maxGrossWeight: dto.maxGrossWeight ?? 0, + status: 'LOADED', + bookingId: null, + }), + ); + } + + const weight = Number(dto.weight) || 0; + const volume = Number(dto.volume) || 0; + this.assertCapacity('Warehouse', warehouse, weight, volume, 1); + this.assertCapacity('Yard', yard, weight, volume, 1); + this.assertCapacity('Zone', zone, weight, volume, 1); + + const owner = dto.companyName?.trim() || null; + const grnNumber = this.generateGrnNumber('WH', 'BACKLOG', arrivedAt, owner); + const saved = await manager.getRepository(WarehouseInventory).save( + manager.getRepository(WarehouseInventory).create({ + warehouseId: dto.warehouseId, + yardId: dto.yardId, + zoneId: dto.zoneId, + bookingId: null, + containerId: container.id, + companyId: dto.companyId ?? null, + companyName: owner, + quantity: 1, + weight, + volume: dto.volume ?? null, + grnNumber, + status: 'RECEIVED', + arrivedAt, + backlogRegistration: true, + notes: this.buildReceiveNote({ + grnNumber, + notes: + dto.notes?.trim() || + `Backlog registration — already in yard, arrived ${arrivedAt.toISOString().slice(0, 10)}`, + }), + }), + ); + + await this.applyCapacityDelta(manager, dto, weight, volume, 1); + return saved.id; + } + } + + /** + * Bulk backlog registration. All-or-nothing: one bad row rejects the sheet, + * so a half-registered yard can never happen. + */ + async bulkRegisterBacklogContainers(dto: BulkRegisterBacklogDto): Promise { + const numbers = dto.containers.map((c) => c.containerNumber.trim().toUpperCase()); + const seen = new Set(); + const repeated = [...new Set(numbers.filter((n) => (seen.has(n) ? true : (seen.add(n), false))))]; + if (repeated.length > 0) { + throw new BadRequestException(`Container number(s) repeated in the upload: ${repeated.join(', ')}`); + } + + const ids = await this.dataSource.transaction(async (manager) => { + const written: string[] = []; + for (const container of dto.containers) { + written.push(await this.saveBacklogContainer(manager, container)); + } + return written; + }); + + return this.inventoryRepository.findAll({ where: { id: In(ids) } }); + } + async receive(dto: ReceiveWarehouseInventoryDto): Promise { const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null; await this.assertExportBookingPaid(dto.bookingId, bookingDirection); @@ -2971,6 +3392,7 @@ export class WarehouseInventoryService { grnNumber, direction: bookingDirection, warehouseId: dto.warehouseId, + bookingId: dto.bookingId ?? null, }); return saved.id; @@ -2996,12 +3418,37 @@ export class WarehouseInventoryService { if ( item.warehouseId === dto.warehouseId && item.yardId === dto.yardId && - item.zoneId === dto.zoneId + item.zoneId === dto.zoneId && + (item.slotId ?? null) === (dto.slotId ?? null) ) { throw new BadRequestException('Destination location is the same as current location'); } const { warehouse, yard, zone } = await this.validateLocation(manager, dto); + + // Taking a box out of a stack is physically impossible while others stand + // on top of it — the same rule the release path enforces. Moving is one of + // those exits, so it is checked here rather than only at release. + if (item.slotId) { + await this.placement.assertAccessible(item.id, manager); + } + + // A move that names a slot is validated against the hierarchy it claims; + // one that does not clears the old slot, because the box has left it. + if (dto.slotId) { + await this.placement.validateSlotForInventory( + { + slotId: dto.slotId, + warehouseId: dto.warehouseId, + yardId: dto.yardId, + zoneId: dto.zoneId, + inventoryId: item.id, + quantity: Number(item.quantity) || 0, + }, + manager, + ); + } + const weight = Number(item.weight) || 0; const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0; @@ -3011,7 +3458,12 @@ export class WarehouseInventoryService { if (item.yardId !== dto.yardId) { this.assertCapacity('Yard', yard, weight, Number(item.volume) || 0, containerCount); } - this.assertCapacity('Zone', zone, weight, Number(item.volume) || 0, containerCount); + // Skipped when the zone is unchanged: a slot-to-slot reshuffle inside one + // zone adds nothing to it, and a full zone would otherwise refuse to let + // its own containers be restacked. + if (item.zoneId !== dto.zoneId) { + this.assertCapacity('Zone', zone, weight, Number(item.volume) || 0, containerCount); + } await this.applyCapacityDelta( manager, @@ -3030,6 +3482,14 @@ export class WarehouseInventoryService { item.warehouseId = dto.warehouseId; item.yardId = dto.yardId; item.zoneId = dto.zoneId; + if (dto.slotId) { + const slot = await this.placement.resolveSlot(dto.slotId, manager); + item.stackId = slot.stackId; + item.slotId = slot.slotId; + } else { + item.stackId = null; + item.slotId = null; + } if (dto.remarks?.trim()) { const existingNotes = item.notes?.trim(); item.notes = existingNotes @@ -3044,12 +3504,171 @@ export class WarehouseInventoryService { return this.findById(movedId); } + // ── Physical slot placement ────────────────────────────────────────────── + + /** + * Pin an inventory item to an exact stack level, or let the placement engine + * pick the lowest free one. Transactional and locked: the row cannot be moved + * out from under the placement between validation and write. + */ + async assignSlot(id: string, slotId?: string, performedBy?: string): Promise { + await this.dataSource.transaction(async (manager) => { + const item = await manager.getRepository(WarehouseInventory).findOne({ + where: { id }, + lock: { mode: 'pessimistic_write' }, + }); + if (!item) throw new NotFoundException(`Inventory item ${id} not found`); + + const criteria = await this.getInventoryAllocationCriteria(item); + const chosenSlotId = + slotId ?? + ( + await this.placement.findAvailableContainerSlot( + { yardId: item.yardId, zoneId: item.zoneId, direction: criteria.tradeDirection }, + manager, + ) + )?.slotId; + + if (!chosenSlotId) { + throw new BadRequestException('No free stack level is available in this zone'); + } + + const slot = await this.placement.validateSlotForInventory( + { + slotId: chosenSlotId, + warehouseId: item.warehouseId, + yardId: item.yardId, + zoneId: item.zoneId, + inventoryId: item.id, + quantity: Number(item.quantity) || 0, + }, + manager, + ); + + await manager.getRepository(WarehouseInventory).update(id, { + stackId: slot.stackId, + slotId: slot.slotId, + notes: this.appendNote(item.notes, `Placed at ${slot.stackCode} level ${slot.level}`), + }); + + await this.activityLog.record( + { + activityType: 'INVENTORY_MOVED', + inventoryId: id, + warehouseId: item.warehouseId, + description: `Placed at ${slot.stackCode} level ${slot.level}`, + performedBy, + }, + manager, + ); + }); + + return this.findById(id); + } + + /** Take the item off its stack level without moving it out of the zone. */ + async releaseSlot(id: string, performedBy?: string): Promise { + await this.dataSource.transaction(async (manager) => { + const item = await manager.getRepository(WarehouseInventory).findOne({ + where: { id }, + lock: { mode: 'pessimistic_write' }, + }); + if (!item) throw new NotFoundException(`Inventory item ${id} not found`); + if (!item.slotId) return; + + // Nothing may be standing on top of it — freeing a buried box would leave + // the containers above it floating over an empty level. + await this.placement.assertAccessible(id, manager); + + await manager.getRepository(WarehouseInventory).update(id, { stackId: null, slotId: null }); + await this.activityLog.record( + { + activityType: 'INVENTORY_MOVED', + inventoryId: id, + warehouseId: item.warehouseId, + description: 'Released from its stack level', + performedBy, + }, + manager, + ); + }); + + return this.findById(id); + } + + /** Whether the box can be lifted out, and what is stacked on top of it if not. */ + getContainerAccessibility(id: string) { + return this.placement.getContainerAccessibility(id); + } + + /** Lowest free stack level for the given yard/zone, without assigning it. */ + findAvailableSlot(input: { + yardId: string; + zoneId?: string; + direction?: string; + cargoTypeId?: string; + }) { + return this.placement.findAvailableContainerSlot(input); + } + + /** + * The physical position an item should take when it is stored. + * + * An explicitly chosen slot is validated and any failure is surfaced — the + * operator asked for that exact level. The automatic path is best-effort: + * a yard with no stacks configured yet, or one that is full, falls back to + * plain zone-level storage rather than blocking a store that worked before + * this model existed. + */ + private async resolveStoragePlacement( + manager: EntityManager, + item: WarehouseInventory, + location: LocationRef, + options: { slotId?: string; direction?: string | null }, + ): Promise<{ stackId: string; slotId: string; label: string } | null> { + const yard = await manager.getRepository(WarehouseYard).findOne({ where: { id: location.yardId } }); + if (yard?.type !== 'CONTAINER_YARD') return null; + + if (options.slotId) { + const slot = await this.placement.validateSlotForInventory( + { + slotId: options.slotId, + warehouseId: location.warehouseId, + yardId: location.yardId, + zoneId: location.zoneId, + inventoryId: item.id, + quantity: Number(item.quantity) || 0, + }, + manager, + ); + return { stackId: slot.stackId, slotId: slot.slotId, label: `${slot.stackCode} level ${slot.level}` }; + } + + // A row still covering several containers has no single position to take. + if ((Number(item.quantity) || 0) > 1) return null; + + try { + const found = await this.placement.findAvailableContainerSlot( + { yardId: location.yardId, zoneId: location.zoneId, direction: options.direction }, + manager, + ); + return found + ? { stackId: found.stackId, slotId: found.slotId, label: `${found.stackCode} level ${found.level}` } + : null; + } catch (error) { + this.logger.debug( + `Automatic slot placement skipped for inventory ${item.id}: ${(error as Error).message}`, + ); + return null; + } + } + // ── Lifecycle transitions ──────────────────────────────────────────────── async store( id: string, performedBy?: string, - chosen?: { warehouseId?: string; yardId?: string; zoneId?: string }, + chosen?: { warehouseId?: string; yardId?: string; zoneId?: string; slotId?: string }, ): Promise { const item = await this.findById(id); this.assertTransition(item.status, 'STORED'); @@ -3113,11 +3732,17 @@ export class WarehouseInventoryService { await this.applyCapacityDelta(manager, location, weight, volume, containerCount); } - const storedReason = manualLocation + const placed = await this.resolveStoragePlacement(manager, locked, location, { + slotId: chosen?.slotId, + direction: criteria.tradeDirection, + }); + + const baseReason = manualLocation ? `Stored at operator-selected location -> ${location.path ?? 'chosen yard/zone'}` : ruleLocation?.rule ? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}` : `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`; + const storedReason = placed ? `${baseReason} @ ${placed.label}` : baseReason; await manager.getRepository(WarehouseInventory).update(id, { status: 'STORED', @@ -3125,6 +3750,8 @@ export class WarehouseInventoryService { warehouseId: location.warehouseId, yardId: location.yardId, zoneId: location.zoneId, + stackId: placed?.stackId ?? null, + slotId: placed?.slotId ?? null, notes: this.appendNote(locked.notes, storedReason), }); @@ -3150,12 +3777,14 @@ export class WarehouseInventoryService { throw new BadRequestException(`Inventory must be STORED to reserve (current: ${item.status})`); } - const status = await this.getBookingStatus(dto.bookingId); - if (!status) { + const paymentStatus = await this.getBookingPaymentStatus(dto.bookingId); + if (paymentStatus === null) { throw new NotFoundException(`Booking ${dto.bookingId} not found`); } - if (status !== 'PAID') { - throw new BadRequestException(`Booking must be PAID to reserve inventory (current: ${status})`); + if (paymentStatus !== 'PAID') { + throw new BadRequestException( + `Booking must be paid to reserve inventory (payment status: ${paymentStatus})`, + ); } await this.dataSource.transaction(async (manager) => { @@ -3647,7 +4276,7 @@ export class WarehouseInventoryService { `SELECT inv.id, inv.release_date AS "releaseDate", inv.release_order_reference AS "releaseOrderReference", - inv.quantity, + COALESCE(receipt_batch.quantity, inv.quantity) AS quantity, inv.weight, inv.status, inv.notes, @@ -3976,11 +4605,12 @@ export class WarehouseInventoryService { */ async bookingContainerWeights( bookingId: string, - ): Promise> { - const rows: Array<{ containerNumber: string; weightTons: string }> = + ): Promise> { + const rows: Array<{ containerNumber: string; weightTons: string; containerSize: string | null }> = await this.dataSource.query( `SELECT bcu.container_number AS "containerNumber", - MAX(COALESCE(bcu.vgm_tons, 0)) AS "weightTons" + MAX(COALESCE(bcu.vgm_tons, 0)) AS "weightTons", + MAX(bc.container_size) AS "containerSize" FROM freight.booking_container_units bcu JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL @@ -3992,6 +4622,7 @@ export class WarehouseInventoryService { return rows.map((r) => ({ containerNumber: r.containerNumber, weightTons: Number(r.weightTons) || 0, + containerSize: r.containerSize ?? null, })); } @@ -4193,7 +4824,7 @@ export class WarehouseInventoryService { -- An unweighed item still reports the cargo weight it holds: fall -- back to the item's container VGM, then the booking's declared -- weight, so a GRN never prints "0 t" for goods that are present. - COALESCE(NULLIF(inv.weight, 0), item_vgm.tons, b.cargo_total_weight_vgm, 0) AS weight, + COALESCE(NULLIF(receipt_batch.weight, 0), NULLIF(inv.weight, 0), item_vgm.tons, b.cargo_total_weight_vgm, 0) AS weight, inv.volume, inv.status, inv.notes, @@ -4210,8 +4841,8 @@ export class WarehouseInventoryService { origin_yard.code AS "originYardCode", destination_yard.label AS "destinationYardLabel", destination_yard.code AS "destinationYardCode", - COALESCE(container.container_number, booking_container.container_number) AS "containerNumber", - booking_container."containerSummary" AS "bookingContainerSummary", + COALESCE(receipt_batch.container_numbers, container.container_number, booking_container.container_number) AS "containerNumber", + COALESCE(receipt_batch.container_summary, booking_container."containerSummary") AS "bookingContainerSummary", COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription", wh.name AS "warehouseName", wh.code AS "warehouseCode", @@ -4241,6 +4872,40 @@ export class WarehouseInventoryService { WHERE bc.booking_id = b.id AND bc.deleted_at IS NULL ) booking_container ON true + LEFT JOIN LATERAL ( + SELECT COUNT(*)::int AS quantity, + SUM(batch.weight) AS weight, + string_agg(batch.container_number, ', ' ORDER BY batch.container_number) + FILTER (WHERE batch.container_number IS NOT NULL) AS container_numbers, + string_agg( + CONCAT(batch.container_number, ' (', COALESCE(batch.container_size, 'size unknown'), ')'), + ', ' ORDER BY batch.container_number + ) FILTER (WHERE batch.container_number IS NOT NULL) AS container_summary + FROM ( + SELECT inv2.id, + inv2.weight, + c2.container_number, + bc2.container_size + FROM freight.warehouse_inventory inv2 + LEFT JOIN freight.containers c2 + ON c2.id = inv2.container_id AND c2.deleted_at IS NULL + LEFT JOIN freight.booking_container_units bcu2 + ON bcu2.container_number = c2.container_number AND bcu2.deleted_at IS NULL + LEFT JOIN freight.booking_container bc2 + ON bc2.id = bcu2.booking_container_id + AND bc2.booking_id = inv2.booking_id + AND bc2.deleted_at IS NULL + WHERE inv2.booking_id = inv.booking_id + AND inv2.deleted_at IS NULL + AND COALESCE( + NULLIF(TRIM(inv2.grn_number), ''), + substring(inv2.notes FROM 'GRN Number: ([^\\n\\r]+)') + ) = COALESCE( + NULLIF(TRIM(inv.grn_number), ''), + substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') + ) + ) batch + ) receipt_batch ON true LEFT JOIN LATERAL ( SELECT SUM(bcu.vgm_tons) AS tons FROM freight.booking_container_units bcu @@ -6029,6 +6694,18 @@ export class WarehouseInventoryService { if (!yard) throw new NotFoundException(`Yard ${dto.yardId} not found`); const zone = await manager.getRepository(WarehouseZone).findOne({ where: { id: dto.zoneId } }); if (!zone) throw new NotFoundException(`Zone ${dto.zoneId} not found`); + + // The three ids arrive independently from the client, so they have to be + // checked against each other: a zone belonging to another yard would send + // the item to a location that does not exist on the ground, and every + // capacity counter above it would be adjusted on the wrong row. + if (zone.yardId !== yard.id) { + throw new BadRequestException(`Zone ${zone.code} does not belong to yard ${yard.code}`); + } + if (yard.warehouseId !== warehouse.id) { + throw new BadRequestException(`Yard ${yard.code} does not belong to warehouse ${warehouse.code}`); + } + return { warehouse, yard, zone }; } @@ -6261,10 +6938,9 @@ export class WarehouseInventoryService { grnNumber: string; direction?: string | null; warehouseId?: string | null; + /** Resolves the company, which unlocks in-app + email alongside the SMS. */ + bookingId?: string | null; }): Promise { - const phone = params.phone?.trim(); - if (!phone) return; - const ownerName = params.ownerName?.trim() || 'Customer'; const bookingReference = params.bookingReference?.trim(); const message = @@ -6274,6 +6950,47 @@ export class WarehouseInventoryService { (params.direction ? `Direction: ${params.direction}. ` : '') + `Thank you.`; + // A booking gives us the company, and with it the customer's inbox and + // email — not just whatever phone number the gate clerk typed. Without one + // (manual or backlog receive) the typed phone is all there is, so the + // original SMS-only path stands. + let companyId: string | null = null; + if (params.bookingId) { + try { + const [row]: Array<{ companyId: string | null }> = await this.dataSource.query( + `SELECT company_id AS "companyId" + FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL`, + [params.bookingId], + ); + companyId = row?.companyId ?? null; + } catch (error) { + this.logger.warn(`GRN ${params.grnNumber}: company lookup failed: ${String(error)}`); + } + } + + if (companyId) { + try { + await this.inbox.notify({ + recipients: { companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.DOCUMENT_ACTION, + title: 'Cargo received — GRN issued', + body: message, + link: params.bookingId ? `/bookings/${params.bookingId}` : undefined, + data: { grnNumber: params.grnNumber, bookingId: params.bookingId ?? null }, + }); + // Sends SMS *and* email to the company's own contacts, so the typed + // phone below is skipped to avoid texting the customer twice. + await sendCompanyChannels(this.dataSource, this.notifications, companyId, message); + return; + } catch (error) { + this.logger.error(`Failed to notify company for GRN ${params.grnNumber}: ${String(error)}`); + } + } + + const phone = params.phone?.trim(); + if (!phone) return; try { await this.notifications.directSend('sms', phone, message); } catch (error) { @@ -6759,12 +7476,19 @@ export class WarehouseInventoryService { }; } - private async getBookingStatus(bookingId: string): Promise { - const [row]: Array<{ status: string | null }> = await this.dataSource.query( - 'SELECT status FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1', + /** + * The booking's PAYMENT status — the only signal loading/reservation gates + * use to decide "paid". Returns null when the booking does not exist; + * an existing booking with no payment status yet reads as PENDING. + */ + private async getBookingPaymentStatus(bookingId: string): Promise { + const [row]: Array<{ paymentStatus: string | null }> = await this.dataSource.query( + `SELECT payment_status AS "paymentStatus" + FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1`, [bookingId], ); - return row?.status ?? null; + if (!row) return null; + return row.paymentStatus ?? 'PENDING'; } private async attachBookingSummaries(items: WarehouseInventory[]): Promise { @@ -6772,7 +7496,8 @@ export class WarehouseInventoryService { if (bookingIds.length === 0) return; const rows: BookingSummaryRow[] = await this.dataSource.query( - `SELECT b.id, b.reference, b.status, company.name AS customer + `SELECT b.id, b.reference, b.status, b.payment_status AS "paymentStatus", + company.name AS customer FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id WHERE b.id = ANY($1) AND b.deleted_at IS NULL`, @@ -6786,6 +7511,7 @@ export class WarehouseInventoryService { Object.assign(item, { bookingReference: summary.reference, bookingStatus: summary.status, + bookingPaymentStatus: summary.paymentStatus, customerName: summary.customer, }); }); 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/modules/warehouses/warehouse-placement.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-placement.service.ts new file mode 100644 index 000000000..7e70a6e15 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-placement.service.ts @@ -0,0 +1,644 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource, EntityManager } from 'typeorm'; + +import { + SLOT_OCCUPYING_STATUSES, + WarehouseInventory, +} from './entities/warehouse-inventory.entity'; +import { SlotEffectiveStatus } from './entities/warehouse-zone-slot.entity'; + +/** The whole chain above one slot, resolved server-side in a single join. */ +export interface SlotHierarchy { + slotId: string; + slotStatus: string; + slotIsActive: boolean; + level: number; + stackId: string; + stackCode: string; + stackStatus: string; + stackIsActive: boolean; + maxStackHeight: number; + zoneId: string; + zoneCode: string; + zoneType: string; + zoneStatus: string; + zoneIsActive: boolean; + yardId: string; + yardCode: string; + yardType: string; + yardDirection: string | null; + yardStatus: string; + yardIsActive: boolean; + warehouseId: string; + warehouseCode: string; + warehouseStatus: string; + warehouseIsActive: boolean; +} + +export interface AvailableSlot { + slotId: string; + stackId: string; + stackCode: string; + level: number; + zoneId: string; + zoneCode: string; +} + +export interface FindSlotInput { + yardId: string; + zoneId?: string | null; + /** IMPORT | EXPORT — matched against the yard's direction (null = BOTH). */ + direction?: string | null; + cargoTypeId?: string | null; +} + +export interface BlockingContainer { + inventoryId: string; + containerNumber: string | null; + level: number; + status: string; +} + +export interface ContainerAccessibility { + accessible: boolean; + inventoryId: string; + stackCode: string | null; + level: number | null; + blockingContainers: BlockingContainer[]; +} + +export interface SlotSummary { + configuredCapacity: number | null; + physicalSlotCount: number; + occupiedSlotCount: number; + reservedSlotCount: number; + blockedSlotCount: number; + inactiveSlotCount: number; + availableSlotCount: number; + /** True when more physical slots are built than the configured capacity allows. */ + inconsistent: boolean; +} + +export interface ZoneLayoutSlot { + slotId: string; + level: number; + effectiveStatus: SlotEffectiveStatus; + inventoryId: string | null; + containerNumber: string | null; +} + +export interface ZoneLayoutStack { + stackId: string; + code: string; + name: string | null; + maxStackHeight: number; + status: string; + isActive: boolean; + slots: ZoneLayoutSlot[]; +} + +export interface ZoneLayout { + zoneId: string; + zoneCode: string; + zoneName: string; + stacks: ZoneLayoutStack[]; + summary: SlotSummary; +} + +/** + * Container identity has two sources and neither covers the other: a backlog + * registration points `warehouse_inventory.container_id` at a `containers` row, + * while booked cargo carries its numbers on `booking_container_units`. Scalar + * subselects rather than joins, so one slot can never fan out into many rows. + * A booking whose units were never split into one inventory row each shows the + * first unit number — placement refuses such rows anyway (see assertSingleUnit). + */ +const CONTAINER_NUMBER_EXPR = `COALESCE( + (SELECT c.container_number FROM freight.containers c + WHERE c.id = i.container_id AND c.deleted_at IS NULL), + (SELECT bcu.container_number FROM freight.booking_container_units bcu + JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = i.booking_id AND bcu.deleted_at IS NULL + ORDER BY bcu.container_number + LIMIT 1) +)`; + +/** A row is "standing in its slot" only in these statuses — same list as the DB's partial unique index. */ +const OCCUPYING = SLOT_OCCUPYING_STATUSES as unknown as string[]; + +/** + * Physical container placement: the stage after allocation. Allocation picks a + * yard (and maybe a zone) from configured rules; this picks the exact stack and + * level, enforces the stacking rules, and answers whether a box can be reached. + * + * Nothing here is called for a non-container yard — bulk, general cargo, + * hazardous and cold storage keep zone-level placement. + */ +@Injectable() +export class WarehousePlacementService { + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + + private em(manager?: EntityManager): EntityManager | DataSource { + return manager ?? this.dataSource; + } + + // ── Hierarchy ───────────────────────────────────────────────────────────── + + /** + * Resolve a slot's full chain up to the warehouse. Ids arriving from a client + * are never trusted against one another — this is the one place the chain is + * established, and every caller compares against what comes back here. + */ + async resolveSlot(slotId: string, manager?: EntityManager): Promise { + const [row] = await this.em(manager).query( + `SELECT sl.id AS "slotId", sl.status AS "slotStatus", sl.is_active AS "slotIsActive", + sl.level AS "level", + s.id AS "stackId", s.code AS "stackCode", s.status AS "stackStatus", + s.is_active AS "stackIsActive", s.max_stack_height AS "maxStackHeight", + z.id AS "zoneId", z.code AS "zoneCode", z.type AS "zoneType", + z.status AS "zoneStatus", z.is_active AS "zoneIsActive", + y.id AS "yardId", y.code AS "yardCode", y.type AS "yardType", + y.direction AS "yardDirection", y.status AS "yardStatus", y.is_active AS "yardIsActive", + w.id AS "warehouseId", w.code AS "warehouseCode", + w.status AS "warehouseStatus", w.is_active AS "warehouseIsActive" + FROM freight.warehouse_zone_slots sl + JOIN freight.warehouse_zone_stacks s ON s.id = sl.stack_id AND s.deleted_at IS NULL + JOIN freight.warehouse_zones z ON z.id = s.zone_id AND z.deleted_at IS NULL + JOIN freight.warehouse_yards y ON y.id = z.yard_id AND y.deleted_at IS NULL + JOIN freight.warehouses w ON w.id = y.warehouse_id AND w.deleted_at IS NULL + WHERE sl.id = $1 AND sl.deleted_at IS NULL`, + [slotId], + ); + + if (!row) throw new NotFoundException(`Slot ${slotId} not found`); + return row as SlotHierarchy; + } + + /** Levels in a stack currently holding a container, lowest first. */ + async occupiedLevels( + stackId: string, + excludeInventoryId?: string | null, + manager?: EntityManager, + ): Promise { + const rows: Array<{ level: number }> = await this.em(manager).query( + `SELECT sl.level AS "level" + FROM freight.warehouse_zone_slots sl + JOIN freight.warehouse_inventory i + ON i.slot_id = sl.id AND i.deleted_at IS NULL AND i.status = ANY($2) + WHERE sl.stack_id = $1 AND sl.deleted_at IS NULL + AND ($3::uuid IS NULL OR i.id <> $3::uuid) + ORDER BY sl.level`, + [stackId, OCCUPYING, excludeInventoryId ?? null], + ); + return rows.map((r) => Number(r.level)); + } + + // ── Placement validation ────────────────────────────────────────────────── + + /** + * Every check that must pass before a container may stand in a slot, in the + * order a yard operator would hit them. Returns the resolved hierarchy so the + * caller writes ids it did not invent. + */ + async validateSlotForInventory( + input: { + slotId: string; + warehouseId: string; + yardId: string; + zoneId: string; + /** Excluded from occupancy checks — the row being moved is allowed to leave its own slot. */ + inventoryId?: string | null; + quantity?: number | null; + }, + manager?: EntityManager, + ): Promise { + const slot = await this.resolveSlot(input.slotId, manager); + + // 1. Hierarchy — the client may not staple a slot onto an unrelated zone/yard/warehouse. + if (slot.zoneId !== input.zoneId) { + throw new BadRequestException( + `Slot ${slot.stackCode}/L${slot.level} belongs to zone ${slot.zoneCode}, not the zone given`, + ); + } + if (slot.yardId !== input.yardId) { + throw new BadRequestException(`Zone ${slot.zoneCode} belongs to yard ${slot.yardCode}, not the yard given`); + } + if (slot.warehouseId !== input.warehouseId) { + throw new BadRequestException( + `Yard ${slot.yardCode} belongs to warehouse ${slot.warehouseCode}, not the warehouse given`, + ); + } + + // 2. Every level of the chain has to be operationally open. + this.assertOperational('Warehouse', slot.warehouseCode, slot.warehouseStatus, slot.warehouseIsActive); + this.assertOperational('Yard', slot.yardCode, slot.yardStatus, slot.yardIsActive); + this.assertOperational('Zone', slot.zoneCode, slot.zoneStatus, slot.zoneIsActive); + this.assertOperational('Stack', slot.stackCode, slot.stackStatus, slot.stackIsActive); + + if (!slot.slotIsActive) { + throw new BadRequestException(`Slot ${slot.stackCode}/L${slot.level} is inactive`); + } + // RESERVED is accepted: a slot is reserved *for* the box now arriving. + if (slot.slotStatus !== 'AVAILABLE' && slot.slotStatus !== 'RESERVED') { + throw new BadRequestException(`Slot ${slot.stackCode}/L${slot.level} is ${slot.slotStatus}`); + } + + // 3. One box per slot. The DB's partial unique index is the backstop; this + // is the readable error the operator actually gets. + const [taken] = await this.em(manager).query( + `SELECT i.id FROM freight.warehouse_inventory i + WHERE i.slot_id = $1 AND i.deleted_at IS NULL AND i.status = ANY($2) + AND ($3::uuid IS NULL OR i.id <> $3::uuid) + LIMIT 1`, + [input.slotId, OCCUPYING, input.inventoryId ?? null], + ); + if (taken) { + throw new ConflictException(`Slot ${slot.stackCode}/L${slot.level} is already occupied`); + } + + if (slot.level > slot.maxStackHeight) { + throw new BadRequestException( + `Level ${slot.level} is above stack ${slot.stackCode}'s maximum height of ${slot.maxStackHeight}`, + ); + } + + // 4. Container yards only: no box may float above an empty level, and a row + // covering several containers has no single physical position. + if (slot.yardType === 'CONTAINER_YARD') { + this.assertSingleUnit(input.quantity); + const occupied = await this.occupiedLevels(slot.stackId, input.inventoryId ?? null, manager); + this.assertStackable(slot, occupied); + } + + return slot; + } + + private assertOperational(label: string, code: string, status: string, isActive: boolean): void { + if (status !== 'ACTIVE' || !isActive) { + throw new BadRequestException(`${label} ${code} is not active`); + } + } + + /** + * A slot is one container. A row that still carries several boxes has no + * single position — split it before placing it, rather than silently pinning + * five containers to one level. + */ + private assertSingleUnit(quantity?: number | null): void { + const qty = Number(quantity ?? 1); + if (qty > 1) { + throw new BadRequestException( + `This inventory row covers ${qty} containers. Split it into one row per container before assigning a slot.`, + ); + } + } + + /** Level N needs every level below it filled — nothing hovers. */ + assertStackable(slot: Pick, occupiedLevels: number[]): void { + if (slot.level === 1) return; + const missing: number[] = []; + for (let level = 1; level < slot.level; level += 1) { + if (!occupiedLevels.includes(level)) missing.push(level); + } + if (missing.length > 0) { + throw new BadRequestException( + `Stack ${slot.stackCode}: level ${slot.level} cannot be filled while level(s) ${missing.join(', ')} are empty`, + ); + } + } + + // ── Finding a slot ──────────────────────────────────────────────────────── + + /** + * Lowest valid free level, deterministic: zone code, then stack code, then + * level. Bottom-up by construction — a stack's candidate level is always one + * above its current top, so level 2 can never be picked before level 1. + * + * Isolated on purpose: a smarter strategy (weight, direction, dwell time) + * swaps in here without touching any caller. + */ + async findAvailableContainerSlot(input: FindSlotInput, manager?: EntityManager): Promise { + const yard = await this.loadYardForPlacement(input, manager); + const zoneIds = await this.candidateZoneIds(yard.id, input.zoneId ?? null, manager); + if (zoneIds.length === 0) return null; + + const [slot] = await this.em(manager).query( + `SELECT sl.id AS "slotId", s.id AS "stackId", s.code AS "stackCode", + sl.level AS "level", z.id AS "zoneId", z.code AS "zoneCode" + FROM freight.warehouse_zone_stacks s + JOIN freight.warehouse_zones z ON z.id = s.zone_id AND z.deleted_at IS NULL + CROSS JOIN LATERAL ( + SELECT COALESCE(MAX(sl2.level), 0) AS top + FROM freight.warehouse_zone_slots sl2 + JOIN freight.warehouse_inventory i2 + ON i2.slot_id = sl2.id AND i2.deleted_at IS NULL AND i2.status = ANY($2) + WHERE sl2.stack_id = s.id AND sl2.deleted_at IS NULL + ) occ + JOIN freight.warehouse_zone_slots sl + ON sl.stack_id = s.id AND sl.deleted_at IS NULL + AND sl.level = occ.top + 1 + AND sl.status = 'AVAILABLE' AND sl.is_active = true + WHERE s.zone_id = ANY($1::uuid[]) + AND s.deleted_at IS NULL AND s.status = 'ACTIVE' AND s.is_active = true + AND occ.top < s.max_stack_height + ORDER BY z.code, s.code, sl.level + LIMIT 1`, + [zoneIds, OCCUPYING], + ); + + return (slot as AvailableSlot) ?? null; + } + + /** Yard gates: active, a container yard, right direction, right cargo type. */ + private async loadYardForPlacement( + input: FindSlotInput, + manager?: EntityManager, + ): Promise<{ id: string; code: string }> { + const [yard] = await this.em(manager).query( + `SELECT y.id, y.code, y.type, y.direction, y.status, y.is_active AS "isActive", + y.capacity_containers AS "capacityContainers", y.current_containers AS "currentContainers", + w.status AS "warehouseStatus", w.is_active AS "warehouseIsActive", w.code AS "warehouseCode" + FROM freight.warehouse_yards y + JOIN freight.warehouses w ON w.id = y.warehouse_id AND w.deleted_at IS NULL + WHERE y.id = $1 AND y.deleted_at IS NULL`, + [input.yardId], + ); + if (!yard) throw new NotFoundException(`Yard ${input.yardId} not found`); + + this.assertOperational('Warehouse', yard.warehouseCode, yard.warehouseStatus, yard.warehouseIsActive); + this.assertOperational('Yard', yard.code, yard.status, yard.isActive); + + if (yard.type !== 'CONTAINER_YARD') { + throw new BadRequestException(`Yard ${yard.code} is a ${yard.type}; container stacking does not apply`); + } + + // Null direction has always meant "takes both" — never treat it as invalid. + const yardDirection = yard.direction ?? 'BOTH'; + const wanted = input.direction ?? 'BOTH'; + if (yardDirection !== 'BOTH' && wanted !== 'BOTH' && yardDirection !== wanted) { + throw new BadRequestException(`Yard ${yard.code} serves ${yardDirection} traffic, not ${wanted}`); + } + + // Empty cargo-type relation = open to any cargo. Preserved deliberately. + if (input.cargoTypeId) { + const [{ allowed }] = await this.em(manager).query( + `SELECT (NOT EXISTS (SELECT 1 FROM freight.warehouse_yard_cargo_types t WHERE t.yard_id = $1) + OR EXISTS (SELECT 1 FROM freight.warehouse_yard_cargo_types t + WHERE t.yard_id = $1 AND t.cargo_type_id = $2)) AS allowed`, + [yard.id, input.cargoTypeId], + ); + if (!allowed) { + throw new BadRequestException(`Yard ${yard.code} does not accept this cargo type`); + } + } + + if (yard.capacityContainers != null && Number(yard.currentContainers) >= Number(yard.capacityContainers)) { + throw new BadRequestException( + `Yard ${yard.code} is at its configured capacity (${yard.currentContainers}/${yard.capacityContainers})`, + ); + } + + return { id: yard.id, code: yard.code }; + } + + /** Active container zones in the yard with configured capacity left, in code order. */ + private async candidateZoneIds( + yardId: string, + zoneId: string | null, + manager?: EntityManager, + ): Promise { + const rows: Array<{ id: string }> = await this.em(manager).query( + `SELECT z.id + FROM freight.warehouse_zones z + WHERE z.yard_id = $1 AND z.deleted_at IS NULL + AND z.status = 'ACTIVE' AND z.is_active = true + AND z.type = 'CONTAINER_ZONE' + AND (z.capacity_containers IS NULL OR z.current_containers < z.capacity_containers) + AND ($2::uuid IS NULL OR z.id = $2::uuid) + ORDER BY z.code`, + [yardId, zoneId], + ); + return rows.map((r) => r.id); + } + + // ── Accessibility ───────────────────────────────────────────────────────── + + /** + * Whether a box can be taken out without touching anything else. Containers + * standing above it block it; nothing is moved to clear the way — a + * relocation is an operator decision, not a side effect of a read. + */ + async getContainerAccessibility(inventoryId: string, manager?: EntityManager): Promise { + const [placed] = await this.em(manager).query( + `SELECT i.id AS "inventoryId", sl.level AS "level", s.id AS "stackId", s.code AS "stackCode" + FROM freight.warehouse_inventory i + LEFT JOIN freight.warehouse_zone_slots sl ON sl.id = i.slot_id AND sl.deleted_at IS NULL + LEFT JOIN freight.warehouse_zone_stacks s ON s.id = sl.stack_id AND s.deleted_at IS NULL + WHERE i.id = $1 AND i.deleted_at IS NULL`, + [inventoryId], + ); + + if (!placed) throw new NotFoundException(`Inventory item ${inventoryId} not found`); + + // No slot = zone-level placement (bulk, or an item that predates the model): + // nothing is stacked on it, so it is reachable. + if (!placed.stackId) { + return { accessible: true, inventoryId, stackCode: null, level: null, blockingContainers: [] }; + } + + const blocking: BlockingContainer[] = await this.em(manager).query( + `SELECT i.id AS "inventoryId", sl.level AS "level", i.status AS "status", + ${CONTAINER_NUMBER_EXPR} AS "containerNumber" + FROM freight.warehouse_zone_slots sl + JOIN freight.warehouse_inventory i + ON i.slot_id = sl.id AND i.deleted_at IS NULL AND i.status = ANY($3) + WHERE sl.stack_id = $1 AND sl.deleted_at IS NULL AND sl.level > $2 + ORDER BY sl.level DESC`, + [placed.stackId, Number(placed.level), OCCUPYING], + ); + + return { + accessible: blocking.length === 0, + inventoryId, + stackCode: placed.stackCode, + level: Number(placed.level), + blockingContainers: blocking.map((b) => ({ ...b, level: Number(b.level) })), + }; + } + + /** Refuse to hand out a box that is buried — used by the exit/delivery paths. */ + async assertAccessible(inventoryId: string, manager?: EntityManager): Promise { + const access = await this.getContainerAccessibility(inventoryId, manager); + if (!access.accessible) { + const above = access.blockingContainers + .map((b) => `${b.containerNumber ?? b.inventoryId} (L${b.level})`) + .join(', '); + throw new ConflictException( + `Container is at ${access.stackCode}/L${access.level} with ${above} stacked above it. Relocate those first.`, + ); + } + } + + // ── Reads ───────────────────────────────────────────────────────────────── + + /** Physical layout of one zone: every stack, every level, what stands there. */ + async zoneLayout(zoneId: string, manager?: EntityManager): Promise { + const [zone] = await this.em(manager).query( + `SELECT z.id, z.code, z.name, z.capacity_containers AS "capacityContainers" + FROM freight.warehouse_zones z WHERE z.id = $1 AND z.deleted_at IS NULL`, + [zoneId], + ); + if (!zone) throw new NotFoundException(`Warehouse zone ${zoneId} not found`); + + const rows: Array<{ + stackId: string; + code: string; + name: string | null; + maxStackHeight: number; + stackStatus: string; + stackIsActive: boolean; + slotId: string | null; + level: number | null; + slotStatus: string | null; + slotIsActive: boolean | null; + inventoryId: string | null; + containerNumber: string | null; + }> = await this.em(manager).query( + `SELECT s.id AS "stackId", s.code AS "code", s.name AS "name", + s.max_stack_height AS "maxStackHeight", s.status AS "stackStatus", + s.is_active AS "stackIsActive", + sl.id AS "slotId", sl.level AS "level", sl.status AS "slotStatus", + sl.is_active AS "slotIsActive", + i.id AS "inventoryId", + CASE WHEN i.id IS NULL THEN NULL ELSE ${CONTAINER_NUMBER_EXPR} END AS "containerNumber" + FROM freight.warehouse_zone_stacks s + LEFT JOIN freight.warehouse_zone_slots sl ON sl.stack_id = s.id AND sl.deleted_at IS NULL + LEFT JOIN freight.warehouse_inventory i + ON i.slot_id = sl.id AND i.deleted_at IS NULL AND i.status = ANY($2) + WHERE s.zone_id = $1 AND s.deleted_at IS NULL + ORDER BY s.code, sl.level DESC`, + [zoneId, OCCUPYING], + ); + + const stacks = new Map(); + for (const row of rows) { + let stack = stacks.get(row.stackId); + if (!stack) { + stack = { + stackId: row.stackId, + code: row.code, + name: row.name, + maxStackHeight: Number(row.maxStackHeight), + status: row.stackStatus, + isActive: row.stackIsActive, + slots: [], + }; + stacks.set(row.stackId, stack); + } + if (row.slotId) { + stack.slots.push({ + slotId: row.slotId, + level: Number(row.level), + effectiveStatus: this.effectiveStatus(row.slotStatus, row.slotIsActive, row.inventoryId), + inventoryId: row.inventoryId, + containerNumber: row.containerNumber, + }); + } + } + + return { + zoneId: zone.id, + zoneCode: zone.code, + zoneName: zone.name, + stacks: [...stacks.values()], + summary: await this.slotSummary({ zoneId }, manager), + }; + } + + private effectiveStatus( + status: string | null, + isActive: boolean | null, + inventoryId: string | null, + ): SlotEffectiveStatus { + if (inventoryId) return 'OCCUPIED'; + if (isActive === false) return 'INACTIVE'; + return (status as SlotEffectiveStatus) ?? 'AVAILABLE'; + } + + /** + * The three numbers that are routinely confused: what was configured, what is + * physically built, and what is actually full. Configured capacity is never + * overwritten from the slot count — a mismatch is reported, not corrected. + */ + async slotSummary( + scope: { zoneId?: string; yardId?: string }, + manager?: EntityManager, + ): Promise { + if (!scope.zoneId && !scope.yardId) { + throw new BadRequestException('A zone or yard is required'); + } + + const [row] = await this.em(manager).query( + `SELECT + (SELECT SUM(z.capacity_containers) + FROM freight.warehouse_zones z + WHERE z.deleted_at IS NULL + AND ($1::uuid IS NULL OR z.id = $1::uuid) + AND ($2::uuid IS NULL OR z.yard_id = $2::uuid)) AS "configuredCapacity", + COUNT(sl.id) AS "physicalSlotCount", + COUNT(i.id) AS "occupiedSlotCount", + COUNT(*) FILTER (WHERE i.id IS NULL AND sl.is_active AND sl.status = 'RESERVED') AS "reservedSlotCount", + COUNT(*) FILTER (WHERE i.id IS NULL AND sl.is_active AND sl.status = 'BLOCKED') AS "blockedSlotCount", + COUNT(*) FILTER (WHERE sl.id IS NOT NULL AND (NOT sl.is_active OR sl.status = 'INACTIVE')) + AS "inactiveSlotCount", + COUNT(*) FILTER (WHERE i.id IS NULL AND sl.is_active AND sl.status = 'AVAILABLE' + AND s.status = 'ACTIVE' AND s.is_active) AS "availableSlotCount" + FROM freight.warehouse_zones z + JOIN freight.warehouse_zone_stacks s ON s.zone_id = z.id AND s.deleted_at IS NULL + LEFT JOIN freight.warehouse_zone_slots sl ON sl.stack_id = s.id AND sl.deleted_at IS NULL + LEFT JOIN freight.warehouse_inventory i + ON i.slot_id = sl.id AND i.deleted_at IS NULL AND i.status = ANY($3) + WHERE z.deleted_at IS NULL + AND ($1::uuid IS NULL OR z.id = $1::uuid) + AND ($2::uuid IS NULL OR z.yard_id = $2::uuid)`, + [scope.zoneId ?? null, scope.yardId ?? null, OCCUPYING], + ); + + const configuredCapacity = row?.configuredCapacity == null ? null : Number(row.configuredCapacity); + const physicalSlotCount = Number(row?.physicalSlotCount ?? 0); + + return { + configuredCapacity, + physicalSlotCount, + occupiedSlotCount: Number(row?.occupiedSlotCount ?? 0), + reservedSlotCount: Number(row?.reservedSlotCount ?? 0), + blockedSlotCount: Number(row?.blockedSlotCount ?? 0), + inactiveSlotCount: Number(row?.inactiveSlotCount ?? 0), + availableSlotCount: Number(row?.availableSlotCount ?? 0), + inconsistent: configuredCapacity != null && physicalSlotCount > configuredCapacity, + }; + } + + /** Free a slot explicitly. Exit paths don't need this — status alone frees it. */ + async releaseSlot(inventoryId: string, manager?: EntityManager): Promise { + await this.em(manager).query( + `UPDATE freight.warehouse_inventory + SET stack_id = NULL, slot_id = NULL, updated_at = now() + WHERE id = $1 AND deleted_at IS NULL`, + [inventoryId], + ); + } + + /** Write a validated placement onto an inventory row inside the caller's transaction. */ + async applyPlacement( + manager: EntityManager, + inventoryId: string, + placement: { stackId: string; slotId: string } | null, + ): Promise { + await manager.getRepository(WarehouseInventory).update(inventoryId, { + stackId: placement?.stackId ?? null, + slotId: placement?.slotId ?? null, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts index 7e658d9db..c51002a5a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { BookingStaff } from '../../common/booking-guards'; @@ -40,6 +40,16 @@ export class WarehouseYardsController { return this.yardsService.update(id, dto); } + @Delete(':id') + @BookingStaff(FREIGHT_PERMS.warehouseYards.delete) + @ApiOperation({ + summary: 'Delete warehouse yard', + description: 'Soft-deletes the yard. Refused while it still has zones.', + }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.yardsService.remove(id); + } + @Get(':yardId/zones') @BookingStaff(FREIGHT_PERMS.warehouseZones.view) @ApiOperation({ summary: 'List zones within a yard' }) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts index 874de75db..b9e212ae1 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts @@ -107,6 +107,24 @@ export class WarehouseYardsService { return this.findById(id); } + /** + * Soft-delete a yard. Zones (and the inventory sitting in them) are left + * alone — a yard still holding zones is refused rather than orphaning stock. + */ + async remove(id: string): Promise<{ id: string; deleted: true }> { + const existing = await this.findById(id); + + if (existing.zones?.length) { + throw new ConflictException( + `Yard ${existing.code} still has ${existing.zones.length} zone(s). Delete them first.`, + ); + } + + await this.yardsRepository.softDelete(id); + + return { id, deleted: true }; + } + private async assertCodeUnique(warehouseId: string, code: string, ignoreId?: string): Promise { const [existing] = await this.yardsRepository.findAll({ where: { warehouseId, code } }); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-slots.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-slots.repository.ts new file mode 100644 index 000000000..f24bcfc33 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-slots.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseZoneSlot } from './entities/warehouse-zone-slot.entity'; + +@Injectable() +export class WarehouseZoneSlotsRepository extends BaseRepository { + constructor(@InjectRepository(WarehouseZoneSlot) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-stacks.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-stacks.controller.ts new file mode 100644 index 000000000..17ce778d0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-stacks.controller.ts @@ -0,0 +1,102 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { + CreateWarehouseZoneStackDto, + UpdateWarehouseZoneSlotDto, + UpdateWarehouseZoneStackDto, +} from './dto/warehouse-zone-stack.dto'; +import { WarehouseZoneStacksService } from './warehouse-zone-stacks.service'; + +/** + * Stacks and slots are zone configuration, so they ride the warehouse-zone + * permissions rather than introducing new keys — a new key needs a matching + * `iam.permissions` row in every environment or boot fails. + */ +@ApiTags('warehouse-zone-stacks') +@ApiBearerAuth() +@Controller('warehouse-zone-stacks') +// Class gate lists every key its routes use: Nest runs class AND method guards. +@BookingStaff([ + FREIGHT_PERMS.warehouseZones.view, + FREIGHT_PERMS.warehouseInventory.view, + FREIGHT_PERMS.warehouseZones.create, + FREIGHT_PERMS.warehouseZones.update, + FREIGHT_PERMS.warehouseZones.delete, +]) +export class WarehouseZoneStacksController { + constructor(private readonly stacksService: WarehouseZoneStacksService) {} + + @Get() + @ApiOperation({ summary: 'List the ground stacks configured in a zone' }) + findByZone(@Query('zoneId', ParseUUIDPipe) zoneId: string) { + return this.stacksService.findByZone(zoneId); + } + + @Post() + @BookingStaff(FREIGHT_PERMS.warehouseZones.create) + @ApiOperation({ + summary: 'Create a ground stack', + description: 'One slot per level is generated automatically, from 1 to maxStackHeight (default 3).', + }) + create(@Body() dto: CreateWarehouseZoneStackDto, @Query('zoneId') zoneIdQuery?: string) { + const zoneId = dto.zoneId ?? zoneIdQuery; + if (!zoneId) { + throw new BadRequestException('zoneId is required'); + } + return this.stacksService.create(zoneId, dto); + } + + // Declared before ':id' so 'slots' is never swallowed as a stack id. + @Patch('slots/:slotId') + @BookingStaff(FREIGHT_PERMS.warehouseZones.update) + @ApiOperation({ + summary: 'Block, reserve, or reactivate one slot', + description: 'Occupancy is derived from inventory and cannot be set here.', + }) + updateSlot(@Param('slotId', ParseUUIDPipe) slotId: string, @Body() dto: UpdateWarehouseZoneSlotDto) { + return this.stacksService.updateSlot(slotId, dto); + } + + @Get(':id') + @ApiOperation({ summary: 'Get one stack with its slots' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.stacksService.findById(id); + } + + @Get(':id/occupancy') + @ApiOperation({ summary: 'Level-by-level occupancy of one stack' }) + occupancy(@Param('id', ParseUUIDPipe) id: string) { + return this.stacksService.slotOccupancy(id); + } + + @Patch(':id') + @BookingStaff(FREIGHT_PERMS.warehouseZones.update) + @ApiOperation({ + summary: 'Update a stack', + description: 'Raising maxStackHeight adds slots; lowering it trims the empty top levels.', + }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseZoneStackDto) { + return this.stacksService.update(id, dto); + } + + @Delete(':id') + @BookingStaff(FREIGHT_PERMS.warehouseZones.delete) + @ApiOperation({ summary: 'Delete a stack', description: 'Refused while containers still stand in it.' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.stacksService.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-stacks.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-stacks.repository.ts new file mode 100644 index 000000000..92a798ec1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-stacks.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseZoneStack } from './entities/warehouse-zone-stack.entity'; + +@Injectable() +export class WarehouseZoneStacksRepository extends BaseRepository { + constructor(@InjectRepository(WarehouseZoneStack) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-stacks.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-stacks.service.ts new file mode 100644 index 000000000..b74116c7a --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-stacks.service.ts @@ -0,0 +1,245 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource, EntityManager, In, IsNull } from 'typeorm'; + +import { + CreateWarehouseZoneStackDto, + UpdateWarehouseZoneSlotDto, + UpdateWarehouseZoneStackDto, +} from './dto/warehouse-zone-stack.dto'; +import { + DEFAULT_MAX_STACK_HEIGHT, + WarehouseZoneStack, +} from './entities/warehouse-zone-stack.entity'; +import { WarehouseZoneSlot } from './entities/warehouse-zone-slot.entity'; +import { WarehousePlacementService } from './warehouse-placement.service'; +import { WarehouseZoneSlotsRepository } from './warehouse-zone-slots.repository'; +import { WarehouseZoneStacksRepository } from './warehouse-zone-stacks.repository'; +import { WarehouseZonesService } from './warehouse-zones.service'; + +/** + * Ground stacks and their vertical slots — the physical layout of a zone. + * + * Slots are never created by hand: a stack of height 3 is three slots, so they + * are generated with the stack and kept in step with its height. That is the + * only way the placement engine can trust `level` to mean what it says. + */ +@Injectable() +export class WarehouseZoneStacksService { + constructor( + private readonly stacksRepository: WarehouseZoneStacksRepository, + private readonly slotsRepository: WarehouseZoneSlotsRepository, + private readonly zonesService: WarehouseZonesService, + private readonly placement: WarehousePlacementService, + @InjectDataSource() private readonly dataSource: DataSource, + ) {} + + findByZone(zoneId: string): Promise { + return this.stacksRepository.findAll({ + where: { zoneId }, + relations: { slots: true }, + order: { code: 'ASC' }, + }); + } + + async findById(id: string): Promise { + const stack = await this.stacksRepository.findById(id, { relations: { slots: true, zone: true } }); + if (!stack) throw new NotFoundException(`Warehouse zone stack ${id} not found`); + stack.slots?.sort((a, b) => a.level - b.level); + return stack; + } + + /** Create the stack and its slots together — a stack with no slots holds nothing. */ + async create(zoneId: string, dto: CreateWarehouseZoneStackDto): Promise { + await this.zonesService.findById(zoneId); + const code = dto.code.trim(); + await this.assertCodeUnique(zoneId, code); + + const maxStackHeight = dto.maxStackHeight ?? DEFAULT_MAX_STACK_HEIGHT; + + const id = await this.dataSource.transaction(async (manager) => { + const stack = await manager.getRepository(WarehouseZoneStack).save( + manager.getRepository(WarehouseZoneStack).create({ + zoneId, + code, + name: dto.name?.trim() ?? null, + row: dto.row?.trim() ?? null, + bay: dto.bay?.trim() ?? null, + position: dto.position?.trim() ?? null, + maxStackHeight, + status: 'ACTIVE', + isActive: true, + }), + ); + + await this.generateSlots(manager, stack.id, 1, maxStackHeight); + return stack.id; + }); + + return this.findById(id); + } + + async update(id: string, dto: UpdateWarehouseZoneStackDto): Promise { + const existing = await this.findById(id); + const code = dto.code?.trim() ?? existing.code; + + if (code !== existing.code) { + await this.assertCodeUnique(existing.zoneId, code, id); + } + + const newHeight = dto.maxStackHeight ?? existing.maxStackHeight; + const status = dto.status ?? existing.status; + + if (status === 'INACTIVE' && existing.status !== 'INACTIVE') { + await this.assertStackEmpty(id, 'deactivated'); + } + + await this.dataSource.transaction(async (manager) => { + if (newHeight > existing.maxStackHeight) { + await this.generateSlots(manager, id, existing.maxStackHeight + 1, newHeight); + } else if (newHeight < existing.maxStackHeight) { + await this.removeSlotsAbove(manager, id, newHeight, existing.code); + } + + await manager.getRepository(WarehouseZoneStack).update(id, { + code, + name: dto.name?.trim() ?? existing.name, + row: dto.row?.trim() ?? existing.row, + bay: dto.bay?.trim() ?? existing.bay, + position: dto.position?.trim() ?? existing.position, + maxStackHeight: newHeight, + status, + isActive: status === 'ACTIVE', + }); + }); + + return this.findById(id); + } + + /** + * Soft-delete a stack. Refused while anything stands in it — the boxes would + * be left pointing at a position every layout query drops. + */ + async remove(id: string): Promise<{ id: string; deleted: true }> { + const existing = await this.findById(id); + await this.assertStackEmpty(id, 'deleted'); + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseZoneSlot).softDelete({ stackId: id }); + await manager.getRepository(WarehouseZoneStack).softDelete(id); + }); + + return { id: existing.id, deleted: true }; + } + + /** + * Set operator intent on one slot. OCCUPIED is not settable — it is derived + * from the inventory sitting there — and a slot holding a box cannot be + * blocked or switched off underneath it. + */ + async updateSlot(slotId: string, dto: UpdateWarehouseZoneSlotDto): Promise { + const slot = await this.slotsRepository.findById(slotId); + if (!slot) throw new NotFoundException(`Warehouse zone slot ${slotId} not found`); + + const status = dto.status ?? slot.status; + const isActive = dto.isActive ?? (dto.status ? dto.status !== 'INACTIVE' : slot.isActive); + const closingOff = status === 'BLOCKED' || status === 'INACTIVE' || isActive === false; + + if (closingOff) { + const [held] = await this.dataSource.query( + `SELECT i.id FROM freight.warehouse_inventory i + WHERE i.slot_id = $1 AND i.deleted_at IS NULL + AND i.status IN ('UNLOADED','RECEIVED','STORED','RESERVED','READY_FOR_LOADING','READY_FOR_PICKUP') + LIMIT 1`, + [slotId], + ); + if (held) { + throw new ConflictException('Slot still holds a container. Move it out first.'); + } + } + + const updated = await this.slotsRepository.update(slotId, { status, isActive }); + if (!updated) throw new NotFoundException(`Warehouse zone slot ${slotId} not found`); + return updated; + } + + /** Occupancy of one stack, level by level. */ + async slotOccupancy(stackId: string): Promise< + Array<{ slotId: string; level: number; effectiveStatus: string; inventoryId: string | null }> + > { + const stack = await this.findById(stackId); + const layout = await this.placement.zoneLayout(stack.zoneId); + const found = layout.stacks.find((s) => s.stackId === stackId); + return (found?.slots ?? []).map((s) => ({ + slotId: s.slotId, + level: s.level, + effectiveStatus: s.effectiveStatus, + inventoryId: s.inventoryId, + })); + } + + // ── internals ───────────────────────────────────────────────────────────── + + /** Idempotent: a level that already exists (e.g. after a height cut and re-raise) is skipped. */ + private async generateSlots( + manager: EntityManager, + stackId: string, + fromLevel: number, + toLevel: number, + ): Promise { + const repository = manager.getRepository(WarehouseZoneSlot); + const existing = await repository.find({ where: { stackId }, withDeleted: true }); + const byLevel = new Map(existing.map((slot) => [slot.level, slot])); + + for (let level = fromLevel; level <= toLevel; level += 1) { + const found = byLevel.get(level); + if (found?.deletedAt) { + // Bring a previously trimmed level back rather than colliding with the + // (stack_id, level) unique index. + await repository.restore(found.id); + await repository.update(found.id, { status: 'AVAILABLE', isActive: true }); + } else if (!found) { + await repository.save(repository.create({ stackId, level, status: 'AVAILABLE', isActive: true })); + } + } + } + + private async removeSlotsAbove( + manager: EntityManager, + stackId: string, + newHeight: number, + stackCode: string, + ): Promise { + const occupied = await this.placement.occupiedLevels(stackId, null, manager); + const stillUsed = occupied.filter((level) => level > newHeight); + if (stillUsed.length > 0) { + throw new BadRequestException( + `Stack ${stackCode}: level(s) ${stillUsed.join(', ')} still hold containers — cannot lower the height to ${newHeight}`, + ); + } + + const doomed = await manager.getRepository(WarehouseZoneSlot).find({ + where: { stackId, deletedAt: IsNull() }, + }); + const ids = doomed.filter((slot) => slot.level > newHeight).map((slot) => slot.id); + if (ids.length > 0) { + await manager.getRepository(WarehouseZoneSlot).softDelete({ id: In(ids) }); + } + } + + private async assertStackEmpty(stackId: string, action: string): Promise { + const occupied = await this.placement.occupiedLevels(stackId); + if (occupied.length > 0) { + throw new ConflictException( + `Stack still holds ${occupied.length} container(s) at level(s) ${occupied.join(', ')}. Move them out before it can be ${action}.`, + ); + } + } + + private async assertCodeUnique(zoneId: string, code: string, ignoreId?: string): Promise { + const [existing] = await this.stacksRepository.findAll({ where: { zoneId, code } }); + if (existing && existing.id !== ignoreId) { + throw new ConflictException(`Stack code ${code} already exists in this zone`); + } + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts index 04cbacd28..c88b8d3d6 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, ParseUUIDPipe, Patch } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { BookingStaff } from '../../common/booking-guards'; @@ -18,6 +18,7 @@ import { WarehouseZonesService } from './warehouse-zones.service'; FREIGHT_PERMS.warehouseZones.view, FREIGHT_PERMS.warehouseInventory.view, FREIGHT_PERMS.warehouseZones.update, + FREIGHT_PERMS.warehouseZones.delete, ]) export class WarehouseZonesController { constructor(private readonly zonesService: WarehouseZonesService) {} @@ -40,4 +41,41 @@ export class WarehouseZonesController { update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseZoneDto) { return this.zonesService.update(id, dto); } + + @Get(':id/contents') + @ApiOperation({ + summary: 'What is currently stored in a zone', + description: 'A row per container — booked units and backlog-registered containers alike.', + }) + contents(@Param('id', ParseUUIDPipe) id: string) { + return this.zonesService.contents(id); + } + + @Get(':id/layout') + @ApiOperation({ + summary: 'Physical layout of a zone', + description: 'Every ground stack with its levels, what stands on each, and the slot summary.', + }) + layout(@Param('id', ParseUUIDPipe) id: string) { + return this.zonesService.layout(id); + } + + @Get(':id/slot-summary') + @ApiOperation({ + summary: 'Configured capacity vs physical slots vs occupancy', + description: 'Flags a zone whose built slots exceed its configured container capacity.', + }) + slotSummary(@Param('id', ParseUUIDPipe) id: string) { + return this.zonesService.slotSummary(id); + } + + @Delete(':id') + @BookingStaff(FREIGHT_PERMS.warehouseZones.delete) + @ApiOperation({ + summary: 'Delete warehouse zone', + description: 'Soft-deletes the zone. Refused while inventory still sits in it.', + }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.zonesService.remove(id); + } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts index 367a5a75e..78011a369 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts @@ -1,16 +1,35 @@ import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto'; import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto'; import { WarehouseZone } from './entities/warehouse-zone.entity'; +import { WarehouseInventoryRepository } from './warehouse-inventory.repository'; +import { WarehousePlacementService } from './warehouse-placement.service'; import { WarehouseYardsService } from './warehouse-yards.service'; import { WarehouseZonesRepository } from './warehouse-zones.repository'; +/** One container (or one bulk lot) currently sitting in a zone. */ +export interface ZoneContentItem { + inventoryId: string; + containerNumber: string | null; + unloadedAt: string | null; + containerType: string | null; + direction: 'IMPORT' | 'EXPORT' | null; + loadState: string | null; + status: string; + bookingReference: string | null; +} + @Injectable() export class WarehouseZonesService { constructor( private readonly zonesRepository: WarehouseZonesRepository, private readonly yardsService: WarehouseYardsService, + private readonly inventoryRepository: WarehouseInventoryRepository, + private readonly placement: WarehousePlacementService, + @InjectDataSource() private readonly dataSource: DataSource, ) {} findAll(): Promise { @@ -98,6 +117,94 @@ export class WarehouseZonesService { return this.findById(id); } + /** + * What is physically sitting in one zone, a row per container. + * + * Container identity has two sources and neither covers the other: booked + * cargo carries its units on `booking_container_units`, while a backlog + * registration has no booking and links `warehouse_inventory.container_id` + * straight to a `containers` row. Bulk cargo has neither, so it comes back + * with a null container number rather than being dropped from its zone. + * + * Full/empty likewise: `containers.status` when there is a container row, + * otherwise a returned unit is the empty one. + */ + async contents(zoneId: string): Promise { + await this.findById(zoneId); + + return this.dataSource.query( + `SELECT i.id AS "inventoryId", + COALESCE(c.container_number, bcu.container_number) AS "containerNumber", + i.unloaded_at AS "unloadedAt", + COALESCE(ct_direct.label, ct_booked.label, bc.container_size) AS "containerType", + b.trade_direction AS "direction", + CASE + WHEN c.status IS NOT NULL THEN c.status + WHEN bcu.is_return THEN 'EMPTY' + WHEN bcu.container_number IS NOT NULL THEN 'FULL' + ELSE NULL + END AS "loadState", + i.status AS "status", + b.reference AS "bookingReference" + FROM freight.warehouse_inventory i + LEFT JOIN freight.containers c ON c.id = i.container_id AND c.deleted_at IS NULL + LEFT JOIN freight.container_types ct_direct ON ct_direct.id = c.container_type_id + LEFT JOIN freight.bookings b ON b.id = i.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.booking_container bc ON bc.booking_id = b.id AND bc.deleted_at IS NULL + LEFT JOIN freight.container_types ct_booked ON ct_booked.id = bc.container_type_id + LEFT JOIN freight.booking_container_units bcu + ON bcu.booking_container_id = bc.id AND bcu.deleted_at IS NULL + WHERE i.zone_id = $1 AND i.deleted_at IS NULL + ORDER BY i.unloaded_at DESC NULLS LAST, + COALESCE(c.container_number, bcu.container_number)`, + [zoneId], + ); + } + + /** The zone's physical layout: every ground stack, every level, what stands there. */ + async layout(zoneId: string) { + await this.findById(zoneId); + return this.placement.zoneLayout(zoneId); + } + + /** Configured capacity vs slots actually built vs slots actually full. */ + async slotSummary(zoneId: string) { + await this.findById(zoneId); + return this.placement.slotSummary({ zoneId }); + } + + /** + * Soft-delete a zone. Inventory points at a zone, so a zone still holding + * stock is refused — soft-deleting it would leave those rows pointing at a + * location every zone-joining query drops. Configured stacks block it for the + * same reason: they would survive their parent and never be reachable again. + */ + async remove(id: string): Promise<{ id: string; deleted: true }> { + const existing = await this.findById(id); + const [, held] = await this.inventoryRepository.findAndCount({ where: { zoneId: id } }); + + if (held > 0) { + throw new ConflictException( + `Zone ${existing.code} still holds ${held} inventory item(s). Move them out first.`, + ); + } + + const [stacks] = await this.dataSource.query( + `SELECT count(*)::int AS count FROM freight.warehouse_zone_stacks + WHERE zone_id = $1 AND deleted_at IS NULL`, + [id], + ); + if (Number(stacks?.count ?? 0) > 0) { + throw new ConflictException( + `Zone ${existing.code} still has ${stacks.count} configured stack(s). Delete them first.`, + ); + } + + await this.zonesRepository.softDelete(id); + + return { id, deleted: true }; + } + private async assertCodeUnique(yardId: string, code: string, ignoreId?: string): Promise { const [existing] = await this.zonesRepository.findAll({ where: { yardId, code } }); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts index 275afadf6..6a2a51b4e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { BookingStaff } from '../../common/booking-guards'; @@ -25,6 +25,7 @@ import { WarehousesService } from './warehouses.service'; FREIGHT_PERMS.warehouseDashboard.view, FREIGHT_PERMS.warehouses.create, FREIGHT_PERMS.warehouses.update, + FREIGHT_PERMS.warehouses.delete, FREIGHT_PERMS.warehouseYards.view, FREIGHT_PERMS.warehouseYards.create, ]) @@ -79,6 +80,16 @@ export class WarehousesController { return this.warehousesService.update(id, dto); } + @Delete(':id') + @BookingStaff(FREIGHT_PERMS.warehouses.delete) + @ApiOperation({ + summary: 'Delete warehouse', + description: 'Soft-deletes the warehouse. Refused while it still has yards.', + }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.warehousesService.remove(id); + } + @Get(':warehouseId/yards') @BookingStaff(FREIGHT_PERMS.warehouseYards.view) @ApiOperation({ summary: 'List yards within a warehouse' }) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index d91ac2baf..48d787e55 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -21,6 +21,8 @@ import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movem import { WarehouseLoading } from './entities/warehouse-loading.entity'; import { WarehouseYard } from './entities/warehouse-yard.entity'; import { WarehouseZone } from './entities/warehouse-zone.entity'; +import { WarehouseZoneSlot } from './entities/warehouse-zone-slot.entity'; +import { WarehouseZoneStack } from './entities/warehouse-zone-stack.entity'; import { Warehouse } from './entities/warehouse.entity'; import { SchedulingReadFacade } from './scheduling-read.facade'; import { WarehouseActivityLogRepository } from './warehouse-activity-log.repository'; @@ -47,6 +49,11 @@ import { WarehouseSchedulingAdapterService } from './warehouse-scheduling-adapte import { WarehouseYardsController } from './warehouse-yards.controller'; import { WarehouseYardsRepository } from './warehouse-yards.repository'; import { WarehouseYardsService } from './warehouse-yards.service'; +import { WarehousePlacementService } from './warehouse-placement.service'; +import { WarehouseZoneSlotsRepository } from './warehouse-zone-slots.repository'; +import { WarehouseZoneStacksController } from './warehouse-zone-stacks.controller'; +import { WarehouseZoneStacksRepository } from './warehouse-zone-stacks.repository'; +import { WarehouseZoneStacksService } from './warehouse-zone-stacks.service'; import { WarehouseZonesController } from './warehouse-zones.controller'; import { WarehouseZonesRepository } from './warehouse-zones.repository'; import { WarehouseZonesService } from './warehouse-zones.service'; @@ -60,6 +67,8 @@ import { WarehousesService } from './warehouses.service'; Warehouse, WarehouseYard, WarehouseZone, + WarehouseZoneStack, + WarehouseZoneSlot, WarehouseInventory, WarehouseInventoryMovement, WarehouseActivityLog, @@ -83,6 +92,7 @@ import { WarehousesService } from './warehouses.service'; WarehousesController, WarehouseYardsController, WarehouseZonesController, + WarehouseZoneStacksController, WarehouseInventoryController, WarehouseLoadingsController, WarehouseInspectionController, @@ -93,6 +103,8 @@ import { WarehousesService } from './warehouses.service'; WarehousesRepository, WarehouseYardsRepository, WarehouseZonesRepository, + WarehouseZoneStacksRepository, + WarehouseZoneSlotsRepository, WarehouseInventoryRepository, WarehouseInventoryMovementRepository, WarehouseActivityLogRepository, @@ -103,6 +115,8 @@ import { WarehousesService } from './warehouses.service'; WarehousesService, WarehouseYardsService, WarehouseZonesService, + WarehouseZoneStacksService, + WarehousePlacementService, WarehouseInventoryService, WarehouseActivityLogService, WarehouseDashboardService, @@ -119,6 +133,8 @@ import { WarehousesService } from './warehouses.service'; WarehousesService, WarehouseYardsService, WarehouseZonesService, + WarehouseZoneStacksService, + WarehousePlacementService, WarehouseInventoryService, WarehouseAllocationService, WarehouseFeeService, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts index 140d9f6b4..43284ca8f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts @@ -54,6 +54,7 @@ export class WarehousesService { name: dto.name.trim(), code: dto.code.trim(), type: dto.type, + freightType: dto.freightType ?? null, stationId: dto.stationId ?? null, facilityId: dto.facilityId ?? null, locationName: dto.locationName?.trim() ?? null, @@ -87,6 +88,7 @@ export class WarehousesService { name: dto.name?.trim() ?? existing.name, code: dto.code?.trim() ?? existing.code, type: dto.type ?? existing.type, + freightType: dto.freightType ?? existing.freightType, stationId: dto.stationId ?? existing.stationId, facilityId: dto.facilityId ?? existing.facilityId, locationName: dto.locationName?.trim() ?? existing.locationName, @@ -108,6 +110,25 @@ export class WarehousesService { return this.findById(id); } + /** + * Soft-delete a warehouse. Yards (and therefore zones and inventory, which + * hang off a zone) are left alone — a warehouse holding them is refused + * rather than silently orphaning stock. + */ + async remove(id: string): Promise<{ id: string; deleted: true }> { + const existing = await this.findById(id); + + if (existing.yards?.length) { + throw new ConflictException( + `Warehouse ${existing.code} still has ${existing.yards.length} yard(s). Delete them first.`, + ); + } + + await this.warehousesRepository.softDelete(id); + + return { id, deleted: true }; + } + /** Map low-level DB errors (FK / length / etc.) to a clean 400 instead of a 500. */ private mapDbError(error: unknown): never { if (error instanceof QueryFailedError) { diff --git a/apps/edr-freight-api/src/scripts/seed-mor-test-buyers.ts b/apps/edr-freight-api/src/scripts/seed-mor-test-buyers.ts new file mode 100644 index 000000000..25ae2f038 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-mor-test-buyers.ts @@ -0,0 +1,100 @@ +import { AppDataSource } from "../data-source"; + +/** + * Seeds the 30 test taxpayers the Ministry of Revenues issued for the EIMS/BSP + * **non-self buyer** certification run — the checklist item that needs a real + * invoice filed against a buyer that is not EDR itself. + * + * Every field comes from MoR's own roster, except the two location codes, which + * do not survive contact with the Ministry's own location master: + * + * - MoR's sheet gives `Region = "1"`. `PARISH_NO 1` is not an Ethiopian region + * at all (it is Djibouti), so the region is stored by name — `Bole` is an + * Addis Ababa sub-city, which fixes the region unambiguously (PARISH_NO 13). + * - MoR's sheet gives `City = "101"`, which is GOFA ZONE in SNNPRS. The buyer + * is in Bole, so the sub-city is stored by name (CITY_NO 78). + * + * In other words MoR does not validate the geographic codes it sends itself; + * these rows carry the addresses that actually resolve. The roster carries no + * woreda, so every row takes `NO WOREDA-144` — MoR's *own* "not specified" + * locality under BOLE (LOCALITY_NO 574), the same code EDR's static seller + * details already file under. Nothing here is invented. + * + * Idempotent: `ON CONFLICT (tin) DO NOTHING`. TIN 0089238373 is already on file + * as a real customer (Afri Software Solutions) and is deliberately left alone. + */ + +/** + * `[TIN, phone, legal name, email, kebele?, house number?]`, verbatim from MoR's roster. The two + * trailing fields default to the values 22 of the 30 rows share. + */ +const KEBELE = "Near Bole Airport"; +const HOUSE_NO = "123B"; + +const MOR_TEST_BUYERS: Array<[string, string, string, string, string?, string?]> = [ + ["0089238373", "251911091245", "Taxpayer A", "codethicaet@gmail.com", "Near Airport", "101"], + ["0054864576", "251911091245", "Taxpayer B", "shehir8@gmail.com", "Near Airport"], + ["0049056594", "251911091245", "Taxpayer C", "teme@odooethiopia.com", "Near Airport"], + ["0059819904", "251911091245", "Taxpayer D", "rubiethoplc@gmail.com", "Near Airport"], + ["0000018932", "251911091245", "Taxpayer E", "amanuelephremedu@gmail.com", "Near Airport"], + ["0088683375", "251911091245", "Taxpayer F", "ermiastegegn576@gmail.com", "Near Airport"], + ["0068421445", "251911091245", "Taxpayer G", "qelemmeda@gmail.com", "Near Airport"], + ["0004404844", "251911091245", "Taxpayer H", "dagnegamu24@gmail.com"], + ["0000037187", "251911091245", "Taxpayer I", "deresr.belay@gmail.com"], + ["0050167460", "251911091245", "Taxpayer J", "sera2013ec@gmail.com"], + ["0079690836", "251909978781", "Taxpayer K", "asmeradefa@gmail.com"], + ["0068180813", "251944310004", "Taxpayer L", "hailelt@gmail.com"], + ["0083907363", "251944310004", "Taxpayer M", "dawitfissha1@gmail.com"], + ["0089032785", "251944310004", "Taxpayer N", "tewahido11@gmail.com"], + ["0003826418", "251944310004", "Taxpayer O", "alemayehu.t@marakisoft.com"], + ["0053374665", "251944310004", "Taxpayer P", "getlelaw@gmail.com"], + ["0016175194", "251911463482", "Taxpayer Q", "abiye.abi@gmail.com", "Near Airport"], + ["0094542975", "251911463482", "Taxpayer R", "abelgebreananya@gmail.com"], + // MoR's roster carries an 11-digit phone here; kept verbatim rather than "corrected". + ["0088514835", "25191124368", "Taxpayer S", "ewnget77@gmail.com"], + ["0076217301", "251960403750", "Taxpayer T", "merontamirat.redcloud@gmail.com"], + ["0003826419", "251911516507", "Taxpayer 322", "alemayehu.t@marakisoft.com"], + ["0056961577", "251929020729", "Taxpayer 323", "ltictsolution@gmail.com", undefined, "1234B"], + ["0090853345", "251911376145", "Taxpayer 324", "kidusgoshu2be@gmail.com"], + ["0000028643", "251988899003", "Taxpayer 325", "mesaysisay10@gmail.com"], + ["0057751727", "251911437928", "Taxpayer 326", "zewdugeta@gmail.com"], + ["0082549522", "251907256543", "Taxpayer 327", "brookgm2@gmail.com"], + ["0093283311", "251953915419", "Taxpayer 328", "henock.ad@gmail.com"], + ["0078795374", "251911091245", "Taxpayer 329", "danielltadesse@gmail.com"], + ["0093346931", "251935724920", "Taxpayer 330", "halidabd63@gmail.com"], + ["0040887091", "251913792959", "Taxpayer 331", "milextech@gmail.com"], +]; + +async function seedMorTestBuyers(): Promise { + await AppDataSource.initialize(); + try { + for (const [tin, phone, name, email, kebele, houseNo] of MOR_TEST_BUYERS) { + await AppDataSource.query( + `INSERT INTO freight.companies + (name, type, kind, status, tin, country, region, zone, woreda, kebele, + house_no, phone, email) + VALUES ($1, 'customer', 'commercial', 'active', $2, 'Ethiopia', 'Addis Ababa', 'Bole', + 'NO WOREDA-144', $3, $4, $5, $6) + ON CONFLICT (tin) DO NOTHING`, + [name, tin, kebele ?? KEBELE, houseNo ?? HOUSE_NO, phone, email], + ); + } + + const summary = await AppDataSource.query( + `SELECT count(*)::int AS on_file, + count(*) FILTER (WHERE name LIKE 'Taxpayer %')::int AS seeded + FROM freight.companies + WHERE tin = ANY($1::text[])`, + [MOR_TEST_BUYERS.map(([tin]) => tin)], + ); + console.table(summary); + console.log("Seeded MoR EIMS/BSP test buyers."); + } finally { + await AppDataSource.destroy(); + } +} + +seedMorTestBuyers().catch((err) => { + console.error("MoR test buyer seed failed:", err); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/scripts/seed-warehouse-layout.ts b/apps/edr-freight-api/src/scripts/seed-warehouse-layout.ts new file mode 100644 index 000000000..d0872a6bb --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-warehouse-layout.ts @@ -0,0 +1,35 @@ +import { AppDataSource } from '../data-source'; +import { WarehouseLayoutSeeder } from '../seed/warehouse-layout.seeder'; + +/** + * Lays out the physical warehouse structure described by + * `src/seed/warehouse-layout.json` — edit that file, not this script. + * + * Idempotent: existing warehouses, yards, zones, stacks and slots are left + * untouched, so a re-run only fills in what is missing. + */ +async function seedWarehouseLayout() { + await AppDataSource.initialize(); + + try { + const summary = await new WarehouseLayoutSeeder(AppDataSource).run(); + console.table([summary]); + + const counts = await AppDataSource.query(` + SELECT + (SELECT COUNT(*)::int FROM freight.warehouses WHERE deleted_at IS NULL) AS warehouses, + (SELECT COUNT(*)::int FROM freight.warehouse_yards WHERE deleted_at IS NULL) AS yards, + (SELECT COUNT(*)::int FROM freight.warehouse_zones WHERE deleted_at IS NULL) AS zones, + (SELECT COUNT(*)::int FROM freight.warehouse_zone_stacks WHERE deleted_at IS NULL) AS stacks, + (SELECT COUNT(*)::int FROM freight.warehouse_zone_slots WHERE deleted_at IS NULL) AS slots + `); + console.table(counts); + } finally { + await AppDataSource.destroy(); + } +} + +seedWarehouseLayout().catch((error) => { + console.error('Failed to seed the warehouse layout:', error); + process.exit(1); +}); 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 af8f6cbd8..0a3d26d71 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1305,6 +1305,11 @@ export const WAREHOUSE_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:warehouse_zones:update", "Update warehouse zone", ), + perm( + "f1c00001-0001-4000-8000-000000000004", + "edr_freight_app:warehouse_zones:delete", + "Delete warehouse zone", + ), perm( "f1d00001-0001-4000-8000-000000000001", "edr_freight_app:warehouse_allocation_rules:view", @@ -1485,6 +1490,22 @@ export const ADDITIONAL_CHARGE_PERMISSIONS: FreightPermissionSeed[] = [ ), ]; +// E''. Empty container return requests — customer asks to send empties back on +// a booking that was sold without the return service; operations price and +// approve it, the customer pays, then books the date and truck. +export const EMPTY_RETURN_REQUEST_PERMISSIONS: FreightPermissionSeed[] = [ + perm( + "f2e00002-0001-4000-8000-000000000001", + "edr_freight_app:empty_return_requests:view", + "View empty container return requests", + ), + perm( + "f2e00002-0001-4000-8000-000000000002", + "edr_freight_app:empty_return_requests:review", + "Approve or reject an empty container return request", + ), +]; + // E'. Train-scheduling finer actions (augment existing view/manage) export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [ perm( @@ -1911,6 +1932,11 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:additional_charges:get_notification", "Receive additional charge notifications", ), + perm( + "f3a00001-0001-4000-8000-00000000000a", + "edr_freight_app:warehouse_inventory:get_notification", + "Receive warehouse desk notifications (containers left behind at loading)", + ), ]; export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ @@ -1927,6 +1953,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ ...WAREHOUSE_PERMISSIONS, ...PORT_TERMINAL_PERMISSIONS, ...ADDITIONAL_CHARGE_PERMISSIONS, + ...EMPTY_RETURN_REQUEST_PERMISSIONS, ...SCHEDULING_EXTRA_PERMISSIONS, ...CONFIG_SETTINGS_PERMISSIONS, ...STAFF_IAM_PERMISSIONS, @@ -2348,6 +2375,7 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:warehouse_zones:view", create: "edr_freight_app:warehouse_zones:create", update: "edr_freight_app:warehouse_zones:update", + delete: "edr_freight_app:warehouse_zones:delete", }, warehouseAllocationRules: { view: "edr_freight_app:warehouse_allocation_rules:view", @@ -2377,6 +2405,12 @@ export const FREIGHT_PERMS = { release: "edr_freight_app:warehouse_inventory:release", deliver: "edr_freight_app:warehouse_inventory:deliver", inspect: "edr_freight_app:warehouse_inventory:inspect", + /** + * Notification selector, not a route guard — who gets pinged when cargo is + * left behind at loading and needs warehouse space. Assign it to whichever + * desk owns that; it grants access to nothing. + */ + getNotification: "edr_freight_app:warehouse_inventory:get_notification", }, interchangeDocuments: { view: "edr_freight_app:interchange_documents:view", @@ -2401,6 +2435,10 @@ export const FREIGHT_PERMS = { // Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS. getNotification: "edr_freight_app:additional_charges:get_notification", }, + emptyReturnRequests: { + view: "edr_freight_app:empty_return_requests:view", + review: "edr_freight_app:empty_return_requests:review", + }, settings: { fileUpload: { view: "edr_freight_app:settings:file_upload:view", @@ -2814,6 +2852,11 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.clearanceReview, FREIGHT_PERMS.contracts.finalizeClearance, FREIGHT_PERMS.contracts.createBooking, + // GL rebooks cancelled-wagon credits on the customer's behalf — whoever + // cancelled (customer or staff) and whichever side was at fault. Needs to + // see the ledger rows and to redeem the credit. + FREIGHT_PERMS.bookings.wagonCancellationView, + FREIGHT_PERMS.bookings.wagonCancellationRebook, FREIGHT_PERMS.contracts.clearanceEtActions, FREIGHT_PERMS.contracts.clearanceDutyAdvise, FREIGHT_PERMS.contracts.finalInvoiceConfirm, diff --git a/apps/edr-freight-api/src/seed/warehouse-layout.json b/apps/edr-freight-api/src/seed/warehouse-layout.json new file mode 100644 index 000000000..5563bf112 --- /dev/null +++ b/apps/edr-freight-api/src/seed/warehouse-layout.json @@ -0,0 +1,28 @@ +{ + "facility": { + "code": "GELAN", + "name": "Gelan Dry Port", + "facilityType": "DRY_PORT" + }, + "levels": ["L1", "L2", "L3", "L4"], + "kinds": [ + { "suffix": "OPEN", "letter": "O", "name": "Open Warehouse", "type": "OPEN_WAREHOUSE" }, + { "suffix": "CLOSED", "letter": "C", "name": "Closed Warehouse", "type": "CLOSED_WAREHOUSE" } + ], + "yards": [ + { "label": "A", "type": "CONTAINER_YARD", "capacityContainers": 150 }, + { "label": "B", "type": "CONTAINER_YARD", "capacityContainers": 150 }, + { "label": "C", "type": "GENERAL_CARGO_YARD", "capacityContainers": null }, + { "label": "D", "type": "BULK_YARD", "capacityContainers": null }, + { "label": "E", "type": "HAZARDOUS_YARD", "capacityContainers": null }, + { "label": "F", "type": "COLD_STORAGE_YARD", "capacityContainers": null } + ], + "zones": [ + { "label": "A", "capacityContainers": 60 }, + { "label": "B", "capacityContainers": 45 }, + { "label": "C", "capacityContainers": 45 } + ], + "stack": { + "maxStackHeight": 3 + } +} diff --git a/apps/edr-freight-api/src/seed/warehouse-layout.seeder.ts b/apps/edr-freight-api/src/seed/warehouse-layout.seeder.ts new file mode 100644 index 000000000..e7533592e --- /dev/null +++ b/apps/edr-freight-api/src/seed/warehouse-layout.seeder.ts @@ -0,0 +1,214 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { DataSource } from 'typeorm'; + +/** + * Builds the physical warehouse layout from `warehouse-layout.json`: + * facility → warehouses (L1-OPEN …) → yards (A–F) → zones (A–C) → ground + * stacks → slots. + * + * The shape is configuration, never enums: yard letters, zone letters and + * stack heights all come from the JSON, because a physical layout changes and + * a deployed enum does not. + * + * Idempotent on every code. A row that already exists is left exactly as it + * is — capacities tuned by hand on a live site must survive a re-run. + */ +export interface WarehouseLayoutConfig { + facility: { code: string; name: string; facilityType: string }; + levels: string[]; + kinds: Array<{ suffix: string; letter: string; name: string; type: string }>; + yards: Array<{ label: string; type: string; capacityContainers: number | null }>; + zones: Array<{ label: string; capacityContainers: number }>; + stack: { maxStackHeight: number }; +} + +export interface LayoutSeedSummary { + facilityCode: string; + warehousesCreated: number; + yardsCreated: number; + zonesCreated: number; + stacksCreated: number; + slotsCreated: number; +} + +export class WarehouseLayoutSeeder { + constructor( + private readonly dataSource: DataSource, + private readonly config: WarehouseLayoutConfig = WarehouseLayoutSeeder.loadConfig(), + ) {} + + static loadConfig(path = join(__dirname, 'warehouse-layout.json')): WarehouseLayoutConfig { + return JSON.parse(readFileSync(path, 'utf8')) as WarehouseLayoutConfig; + } + + async run(): Promise { + const summary: LayoutSeedSummary = { + facilityCode: this.config.facility.code, + warehousesCreated: 0, + yardsCreated: 0, + zonesCreated: 0, + stacksCreated: 0, + slotsCreated: 0, + }; + + const facilityId = await this.upsertFacility(); + + for (const level of this.config.levels) { + for (const kind of this.config.kinds) { + const warehouseCode = `${level}-${kind.suffix}`; + const warehouse = await this.upsertWarehouse(facilityId, warehouseCode, `${level} ${kind.name}`, kind.type); + summary.warehousesCreated += warehouse.created ? 1 : 0; + + for (const yardCfg of this.config.yards) { + const yardCode = `${level}-${kind.letter}-${yardCfg.label}`; + const yard = await this.upsertYard(warehouse.id, yardCode, `Yard ${yardCfg.label}`, yardCfg); + summary.yardsCreated += yard.created ? 1 : 0; + + // Zones, stacks and slots are only laid out for container yards — + // bulk and general cargo do not stand in numbered positions. + if (yardCfg.type !== 'CONTAINER_YARD') continue; + + for (const zoneCfg of this.config.zones) { + const zoneCode = `${yardCode}-Z${zoneCfg.label}`; + const zone = await this.upsertZone(yard.id, zoneCode, `Zone ${zoneCfg.label}`, zoneCfg.capacityContainers); + summary.zonesCreated += zone.created ? 1 : 0; + + const height = this.config.stack.maxStackHeight; + // Ground positions, not boxes: a 60-container zone stacked three + // high needs 20 patches of concrete. + const stackCount = Math.floor(zoneCfg.capacityContainers / height); + + for (let n = 1; n <= stackCount; n += 1) { + const stackCode = `Z${zoneCfg.label}-${String(n).padStart(3, '0')}`; + const stack = await this.upsertStack(zone.id, stackCode, height); + summary.stacksCreated += stack.created ? 1 : 0; + summary.slotsCreated += await this.upsertSlots(stack.id, height); + } + } + } + } + } + + return summary; + } + + private async upsertFacility(): Promise { + const { code, name, facilityType } = this.config.facility; + const [existing] = await this.dataSource.query( + `SELECT id FROM freight.facilities WHERE code = $1 AND deleted_at IS NULL`, + [code], + ); + if (existing) return existing.id; + + const [created] = await this.dataSource.query( + `INSERT INTO freight.facilities (code, name, facility_type, facility_status, is_active) + VALUES ($1, $2, $3, 'ACTIVE', true) + RETURNING id`, + [code, name, facilityType], + ); + return created.id; + } + + private async upsertWarehouse( + facilityId: string, + code: string, + name: string, + type: string, + ): Promise<{ id: string; created: boolean }> { + const [existing] = await this.dataSource.query( + `SELECT id FROM freight.warehouses WHERE code = $1 AND deleted_at IS NULL`, + [code], + ); + if (existing) return { id: existing.id, created: false }; + + const [created] = await this.dataSource.query( + `INSERT INTO freight.warehouses (code, name, type, facility_id, status, is_active, + current_weight, current_containers, current_volume) + VALUES ($1, $2, $3, $4, 'ACTIVE', true, 0, 0, 0) + RETURNING id`, + [code, name, type, facilityId], + ); + return { id: created.id, created: true }; + } + + private async upsertYard( + warehouseId: string, + code: string, + name: string, + cfg: { type: string; capacityContainers: number | null }, + ): Promise<{ id: string; created: boolean }> { + const [existing] = await this.dataSource.query( + `SELECT id FROM freight.warehouse_yards + WHERE warehouse_id = $1 AND code = $2 AND deleted_at IS NULL`, + [warehouseId, code], + ); + if (existing) return { id: existing.id, created: false }; + + const [created] = await this.dataSource.query( + `INSERT INTO freight.warehouse_yards (warehouse_id, code, name, type, capacity_containers, + status, is_active, current_weight, current_containers, current_volume) + VALUES ($1, $2, $3, $4, $5, 'ACTIVE', true, 0, 0, 0) + RETURNING id`, + [warehouseId, code, name, cfg.type, cfg.capacityContainers], + ); + return { id: created.id, created: true }; + } + + private async upsertZone( + yardId: string, + code: string, + name: string, + capacityContainers: number, + ): Promise<{ id: string; created: boolean }> { + const [existing] = await this.dataSource.query( + `SELECT id FROM freight.warehouse_zones WHERE yard_id = $1 AND code = $2 AND deleted_at IS NULL`, + [yardId, code], + ); + if (existing) return { id: existing.id, created: false }; + + const [created] = await this.dataSource.query( + `INSERT INTO freight.warehouse_zones (yard_id, code, name, type, capacity_containers, + status, is_active, current_weight, current_containers, current_volume) + VALUES ($1, $2, $3, 'CONTAINER_ZONE', $4, 'ACTIVE', true, 0, 0, 0) + RETURNING id`, + [yardId, code, name, capacityContainers], + ); + return { id: created.id, created: true }; + } + + private async upsertStack( + zoneId: string, + code: string, + maxStackHeight: number, + ): Promise<{ id: string; created: boolean }> { + const [existing] = await this.dataSource.query( + `SELECT id FROM freight.warehouse_zone_stacks WHERE zone_id = $1 AND code = $2 AND deleted_at IS NULL`, + [zoneId, code], + ); + if (existing) return { id: existing.id, created: false }; + + const [created] = await this.dataSource.query( + `INSERT INTO freight.warehouse_zone_stacks (zone_id, code, max_stack_height, status, is_active) + VALUES ($1, $2, $3, 'ACTIVE', true) + RETURNING id`, + [zoneId, code, maxStackHeight], + ); + return { id: created.id, created: true }; + } + + private async upsertSlots(stackId: string, height: number): Promise { + const result = await this.dataSource.query( + `INSERT INTO freight.warehouse_zone_slots (stack_id, level, status, is_active) + SELECT $1, lvl, 'AVAILABLE', true + FROM generate_series(1, $2) AS lvl + WHERE NOT EXISTS ( + SELECT 1 FROM freight.warehouse_zone_slots s + WHERE s.stack_id = $1 AND s.level = lvl AND s.deleted_at IS NULL + ) + RETURNING id`, + [stackId, height], + ); + return Array.isArray(result) ? result.length : 0; + } +} diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index a2af15786..f95fe7cca 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -96,6 +96,8 @@ import TrainBuilderDetailPage from "./pages/trainBuilder/TrainBuilderDetailPage" import TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage"; import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; import ContainerReturnsPage from "./pages/warehouses/ContainerReturnsPage"; +import EmptyReturnRequestsPage from "./pages/warehouses/EmptyReturnRequestsPage"; +import RegisterFullContainersPage from "./pages/warehouses/RegisterFullContainersPage"; import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage"; import ExportWarehouseFlowPage from "./pages/warehouses/ExportWarehouseFlowPage"; @@ -708,6 +710,29 @@ const App = () => { } /> + + + + } + /> + + + + } + /> { AUTH_TOKEN_COOKIE, REFRESH_TOKEN_COOKIE, AUTH_USER_COOKIE, + POSITION_COOKIE, + // Pre-rename name, still cleared so a stale value cannot outlive logout. "current-position-id", "selected-position-id", ].forEach(clearCookie); diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx index 814fb9a95..0cf206350 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx @@ -1,12 +1,32 @@ import { Banknote, Receipt } from "lucide-react"; import { Divider, Group, Paper, Stack, Text } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/services/api"; import type { BookingDetail } from "@/types/booking"; import { SectionCard } from "./detail/SectionCard"; import { detailStyles } from "./detail/booking-detail.styles"; export function BookingPricingSummary({ booking }: { booking: BookingDetail }) { + // The booking's own freight invoice: source `booking`, sourceId = booking id + // (which `search` matches). Newest first — a re-issue supersedes the old one. + const invoiceQuery = useQuery( + api.invoices.list.queryOptions({ + input: { + filter: { + page: 1, + pageSize: 1, + sources: "booking", + search: booking.id, + sortBy: "createdAt", + sortOrder: "DESC", + }, + }, + }), + ); + const invoiceNumber = invoiceQuery.data?.items[0]?.invoiceNumber ?? null; + const computed = Number(booking.totalAmount); // The booking price is computed from the contract and is NOT staff-editable. // A historical `adjustedTotalAmount` (from before adjustments were removed) @@ -49,6 +69,7 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) { + {invoiceNumber && } {booking.pnrCode && } {lineItems.length > 0 && ( diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx new file mode 100644 index 000000000..2d37be840 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx @@ -0,0 +1,235 @@ +import { useEffect, useState } from "react"; +import { Button, Group, Modal, Select, Stack, Text, TextInput } from "@mantine/core"; +import { DatePickerInput } from "@mantine/dates"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import toast from "react-hot-toast"; + +import { api } from "@/auth/http"; +import { toDayString } from "@/hooks/useListControls"; +import { formatMoney } from "@/lib/format"; +import { + hasOddFt20, + type RebookPartnerCandidate, + type WagonCancellation, +} from "./types"; + +/** Editable rebook unit — prefilled from the cancelled snapshot. */ +interface RebookUnitDraft { + containerSize: string; + containerNumber: string; + sealNumber: string; + vgmTons: number | ""; +} + +const draftsFrom = (r: WagonCancellation): RebookUnitDraft[] => + (r.cancelledQuantities?.units ?? []).map((u) => ({ + containerSize: u.containerSize, + containerNumber: u.containerNumber, + sealNumber: u.sealNumber ?? "", + vgmTons: Number(u.vgmTons) || "", + })); + +const containersPayload = (drafts: RebookUnitDraft[]) => { + const bySize = new Map(); + for (const d of drafts) { + bySize.set(d.containerSize, [...(bySize.get(d.containerSize) ?? []), d]); + } + return [...bySize.entries()].map(([containerSize, units]) => ({ + containerSize, + units: units.map((u) => ({ + containerNumber: u.containerNumber.trim(), + ...(u.sealNumber.trim() ? { sealNumber: u.sealNumber.trim() } : {}), + ...(u.vgmTons !== "" ? { vgmTons: Number(u.vgmTons) } : {}), + })), + })); +}; + +/** + * Staff/GL rebook of a CREDIT_AVAILABLE wagon cancellation: pick the shipment + * day, correct container details if they changed, and — for an odd-20ft + * credit — pick the consolidation partner that shares the wagon. The server + * creates the new booking under the contract and marks it PAID from the credit. + * Used by the wagon-cancellations list, the GL clearance page and the staff + * booking page, so every desk gets the same flow. + */ +export function RebookWagonCancellationModal({ + cancellation, + onClose, + onRebooked, +}: { + cancellation: WagonCancellation | null; + onClose: () => void; + /** Called after a successful rebook with the new booking id (when the API returns it). */ + onRebooked?: (result: { bookingId?: string }) => void; +}) { + const [date, setDate] = useState(null); + const [partnerId, setPartnerId] = useState(null); + const [drafts, setDrafts] = useState([]); + + // Fresh form per row: the modal instance is long-lived on the host page. + useEffect(() => { + setDate(null); + setPartnerId(null); + setDrafts(cancellation ? draftsFrom(cancellation) : []); + }, [cancellation]); + + const needsPartner = cancellation ? hasOddFt20(cancellation) : false; + const partners = useQuery({ + queryKey: [ + "wagon-cancellations", + cancellation?.id, + "rebook-partners", + date ? toDayString(date) : null, + ], + enabled: Boolean(cancellation && needsPartner && date), + queryFn: async () => { + const res = await api.get( + `/bookings/wagon-cancellations/${cancellation!.id}/rebook-partners`, + { params: { scheduledDate: toDayString(date!) } }, + ); + return res.data; + }, + }); + + const rebook = useMutation({ + mutationFn: async () => { + const res = await api.post<{ bookingId?: string }>( + `/bookings/wagon-cancellations/${cancellation!.id}/rebook`, + { + scheduledDate: toDayString(date!), + ...(drafts.length ? { containers: containersPayload(drafts) } : {}), + ...(partnerId ? { partnerBookingId: partnerId } : {}), + }, + ); + return res.data ?? {}; + }, + }); + + const patchDraft = (i: number, patch: Partial) => + setDrafts((prev) => prev.map((x, idx) => (idx === i ? { ...x, ...patch } : x))); + + return ( + + {cancellation && ( + + + {cancellation.booking?.reference ?? cancellation.bookingId} ·{" "} + {cancellation.wagonsCancelled} wagon(s) · credit{" "} + {formatMoney(cancellation.creditAmount, cancellation.feeCurrency, 2)} + + { + setDate(v ? new Date(v) : null); + setPartnerId(null); + }} + minDate={new Date()} + radius="md" + /> + {needsPartner && ( +