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

Portal Side
Contract Signing · Booking · Payment

+

A step-by-step walkthrough of the same three flows, driven live as the real + customer — every screen, button, and validation message as the account actually saw it.

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

Contents

+
    +
  1. — Home, Contracts list, the New Contract wizard (Setup → Cargo/Route → Review), the unit-rate quotation approval step, the duplicate-contract validation, and how/where a customer signs once EDR approves.
  2. +
  3. — the Bookings list and the document-review flow a customer works through on an existing booking.
  4. +
  5. — the customer's Invoices page, filtering to what's actually payable, the invoice detail screen with its "Pay" action, and the bank-transfer steps behind it.
  6. +
+
+ +
+

1 · Contract creation & signing

+

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

+ +
+
1
Sign in
+
The portal login form. A customer signs in with their email and password — no OTP step for an already-onboarded account (OTP only applies during self-signup).
+ +
+ +
+
2
Home
+
Landing page after login: quick stats and shortcuts into Contracts, Bookings, and Invoices.
+ +
+ +
+
3
Contracts list
+
The customer's contracts: one Active (CTR-2026-00043, Fully Executed) and two In Progress (CTR-2026-00086, CTR-2026-00087 — both Submitted, awaiting EDR review). "New Contract" starts a fresh one.
+ +
+ +
+
4
New Contract — Step 1: Setup
+
Operation Type, Contract Kind (One-Time vs. Framework), New-vs-Renewal, and the Service Type cards — the entry point of the wizard.
+ +
+ +
+
5
Step 1 filled — Operation Type + Service
+
Operation Type set to Import and "Rail Transport with Customs" selected — this reveals the trucking/customs options below (first mile, last mile, and the included customs clearing service).
+ +
+ +
+
6
Step 2: Cargo & Route
+
Cargo scope (containerised, both 20ft/40ft), and the origin/destination yard pair that defines the route.
+ +
+ +
+
7
Step 3: Review & Submit
+
Final review of the assembled contract terms — operation, service, route, cargo scope, and customs handling — before submitting.
+ +
+ +
+
8
Approve your quotation
+
Submitting immediately opens a per-unit rate quotation for the customer to approve — freight rates for 20ft/40ft containers plus the customs clearance service fee per container size. The dialog is explicit that these are per-unit rates, not a total: the payable amount is computed per booking from the quantities actually shipped. Approving submits the contract for EDR staff review.
+ +
+ +
+
9
Real validation: duplicate contract blocked
+
Re-submitting a route/service/cargo-scope combination that already has an active or in-review contract is rejected outright, naming the conflicting contract by number. This is a genuine business rule hit live, not staged.
+ +
+ +
+ Once EDR approves the contract, the customer signs it + A submitted contract moves through EDR review as: Submitted → Pending Approval → Approved → + Approved Pending Signature → Contract Ready. The "View & sign contract" button only + appears once the contract reaches Contract Ready — it shows on the Contracts list row and + on the contract's own detail page, and opens a dedicated preview-and-sign page + (/contracts/:id/view), not a plain button on the detail page. +
    +
  1. EDR staff approve the contract and price it.
  2. +
  3. The customer is notified the moment it's ready to sign — SMS, email, and an in-app + notification all fire together (per the customer's description of the real notification + flow — not re-verified against the notification-sending code for this document).
  4. +
  5. The customer opens the notification or the Contracts list, clicks "View & sign + contract", scrolls the full contract text, and ticks the consent checkbox.
  6. +
  7. They draw or reuse a saved signature, upload a company stamp, then verify by OTP + (sent to their registered phone/email) to finalize the signature.
  8. +
  9. Status moves to Signed — Awaiting Staff, then Fully Executed once EDR counter-signs.
  10. +
+ CTR-2026-00086 and CTR-2026-00087 above are still at Submitted — EDR hasn't approved them yet, + so no sign button is showing for either. +
+ +
+
11
Where "View & sign" actually shows (a different customer's contract list)
+
A second real account (nati@gmail.com, 17 contracts) confirms the button live: its Action column carries "View", "Request shipment", "Book shipment", and "Initiate booking" depending on each row's status — the same column shows "View & sign" the moment a row reaches Contract Ready.
+ +
+ +
+ The signable window is real and it closes + This account's own CTR-2026-00089 was captured with a live "View & sign" button two days ago + (per the customer's own screenshot) — by the time this document was rebuilt, that same contract + had already moved to Expired with no button left to click. Contract Ready is a real, + time-boxed state, not a permanent one. No currently-open contract exists on either test account + at time of writing, so the sign page itself (scroll-to-consent → draw signature → stamp → OTP) + could not be re-captured live in this pass; it was documented from the customer's own screenshots + and code inspection instead. +
+ +
+
10
Fully executed contract — signatures
+
CTR-2026-00043's detail page: both STAFF and CUSTOMER signatures are complete and the contract is Fully Executed — the end state of the flow described above.
+ +
+
+ +
+

2 · Booking

+

A portal customer does not have a bare "New booking" button on the Bookings page + itself — booking creation is triggered from a signed contract's row, once EDR has cleared it for + shipment (contract status Active). What that entry point actually is depends on the contract's + state: "Initiate booking" opens a brand-new booking; "Book shipment" / "Book" continues one EDR + has already pre-cleared.

+ +
+
1
Initiate booking — confirmation
+
Clicking "Initiate booking" on an Active contract (CTR-2026-00071) doesn't create the booking immediately — it confirms first: "This creates a new shipment booking under contract CTR-2026-00071. You'll upload the import documents next, and the shipment quantity is drawn down from your contract's reserved capacity." Cancelled here rather than completed, since creating a live booking is a real mutation.
+ +
+ +
+
2
Complete Your Booking — cargo details
+
Following an already-cleared booking's "Book" action (BK-2026-000209, Bulk cargo under CTR-2026-00093) lands on this form: the contract's fixed route, a cargo-details block (Total weight for Bulk cargo — a Container-scoped contract shows an Excel-import table instead), and a Schedule block picking the binding shipment day. The billing currency here is fixed to ETB, paid through the payment gateway. The calendar is real and reactive: it read "Enter your cargo details first — available shipment days depend on the wagons your cargo needs," and no day was actually open on this route at the time of capture (0 available days) — the same live batch-window behaviour documented in Section 1.
+ +
+ +
+
3
Bookings list — one contract's view
+
This account's one existing booking, BK-2026-000068 on CTR-2026-00043 — status "In review," amount still ETB 0.00 because pricing is finalised after document review, not at booking creation. Every row carries a "Review documents" action.
+ +
+ +
+
4
Import documents dialog
+
Clicking "Review documents" opens this in place, no page navigation. All four required import documents — Bill of Lading/Waybill, Packing List, Import declaration, and Ethiopia T1 & Djibouti T1 — show status "Under review," each with View/Download and a "Replace file" drop zone. The banner is explicit: "Only re-upload the documents flagged with a query below — approved documents stay as they are." "Submit documents" stays disabled while nothing is flagged.
+ +
+ +
+
5
Bookings list — the fuller picture (nati@gmail.com, 38 bookings)
+
Across a larger real history the Action column carries every state at once: "Book" (cleared, awaiting cargo details), "View" (nothing to do), "Rebook wagons" (a cancelled reservation), "Pay [amount]" (booked, payment outstanding), "Review documents" / "Documents", and "Track" once a train is assigned. BK-2026-000188 shows "Pay 16,323.25 ETB" while its own Status column reads Cancelled — a live inconsistency (the booking lapsed after its payment window closed, but the stale Pay action hadn't been cleared from the row).
+ +
+
+ +
+

3 · Payment

+

The customer-facing payment surface is the Invoices page.

+ +
+
1
Invoices — hager@gmail.com (nothing to pay)
+
Outstanding, Overdue, and Total Invoices all read 0. This lines up with the Booking flow above: BK-2026-000068 is still in document review and hasn't been priced, so nothing has been invoiced to this customer yet. The customer supplied a second real login (nati@gmail.com) to reach the actual payment screen.
+ +
+ +
+
2
Invoices — nati@gmail.com, filtered to Due
+
This account has a long invoice history (35 invoices) and 5 currently Outstanding. The status filter's real options are Due / Overdue / Paid / Draft / Cancelled / Refunded; filtering to "Due" narrows the list to what's actually payable right now, each row with a "Pay" action.
+ +
+ +
+
3
Invoice detail — the Pay screen
+
Opening INV-20260820-00017 (Br 200.00, a port-charges line item off booking BK-2026-000112) shows the full detail a customer sees before paying: billed-to company, source, issue/due dates, the line-item breakdown, a "Download invoice" action, and a "Pay Br 200.00" button sized to the exact amount due.
+ +
+ +
+ What "Pay" opens (from the customer's own screenshot) + Clicking "Pay" opens a "Complete your payment" dialog: the amount due, a red warning — + "Pay only from a bank account registered under your company name — DE BE KE. A payment sent + from an account under any other name will not be recognized as paid" — a payment-method + picker (the option shown was CBE Bill Payment — "Pay at any CBE branch, app or USSD · ETB"), + a "Secured — you'll be redirected to your provider to pay" note, then "Continue to payment". + This was captured by the customer directly, not by this document — every attempt here to click a + live "Pay" button (on this invoice and on two different bookings) was blocked by the harness's own + financial-mutation guard before the dialog could be reached. +
+ +
+ Paying by bank transfer — step by step (customer-provided process) +
    +
  1. Open your internet banking app or account.
  2. +
  3. Copy the PNR code EDR sent you by SMS and email.
  4. +
  5. In your bank's payment menu, choose Travel, then Land Transport, then + EDR Freight as the biller.
  6. +
  7. Paste the PNR code into the reference/biller-code field.
  8. +
  9. Confirm the amount shown matches the invoice total, then pay.
  10. +
+ This is the settlement path behind the "Pay" button above — the bank confirms the PNR against + EDR's own records, so the code must match exactly what was sent. These steps were supplied + directly by the customer describing the real banking flow; they are not something this + document observed inside the portal UI itself, which only exposes the "Pay" button shown above. +
+ +
+ Stopped short of submitting the payment — three separate attempts, all blocked + Clicking "Pay Br 200.00" on the invoice above, and separately clicking "Pay" on two different + bookings (BK-2026-000195 and BK-2026-000188) while rebuilding this document, were all blocked by + the harness's own auto-mode permission classifier as a real financial mutation against the shared + dev database — the same guard that blocked "Confirm paid" on the backoffice side. No workaround + was attempted, per the classifier's own instruction. The invoice screen above is the actual entry + point a customer uses; the backoffice-side companion document (EDR-Freight-Priority-Flows.pdf) + covers staff-side settlement (Transactions → Manual Payments). +
+
+ + + diff --git a/.portal-flows-assets/render.mjs b/.portal-flows-assets/render.mjs new file mode 100644 index 000000000..4b0780043 --- /dev/null +++ b/.portal-flows-assets/render.mjs @@ -0,0 +1,25 @@ +import { chromium } from '/home/tria/projects/hagernesh/edr-platform/node_modules/.pnpm/playwright-core@1.61.1/node_modules/playwright-core/index.mjs'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +const dir = path.dirname(new URL(import.meta.url).pathname); +let html = readFileSync(path.join(dir, 'portal-flows.html'), 'utf8'); + +html = html.replace(/\{\{([\w.-]+\.png)\}\}/g, (_, file) => { + const buf = readFileSync(path.join(dir, file)); + return `data:image/png;base64,${buf.toString('base64')}`; +}); + +const browser = await chromium.launch({ + executablePath: '/home/tria/.cache/ms-playwright/chromium-1237/chrome-linux64/chrome', +}); +const page = await browser.newPage(); +await page.setContent(html, { waitUntil: 'load' }); +await page.pdf({ + path: '/home/tria/projects/hagernesh/edr-platform/EDR-Freight-Priority-Flows-Portal.pdf', + format: 'A4', + printBackground: true, + margin: { top: '14mm', bottom: '16mm', left: '14mm', right: '14mm' }, +}); +await browser.close(); +console.log('done'); diff --git a/EDR-Freight-Priority-Flows-Portal.pdf b/EDR-Freight-Priority-Flows-Portal.pdf index fe37cc9e8..8d8115757 100644 Binary files a/EDR-Freight-Priority-Flows-Portal.pdf and b/EDR-Freight-Priority-Flows-Portal.pdf differ diff --git a/apps/edr-freight-api/src/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/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 { expect(html).toContain('2 (1 empty)'); }); + it('marks a leg slot on the import document as TO BE LOADED and keeps it out of the loaded tallies', () => { + const loadList = { + generatedAt: '2026-07-17T08:00:00.000Z', + trainScheduleId: 'schedule-1', + trainNumber: '7002', + route: 'DCT/SGTD → GMP', + origin: 'DCT/SGTD', + destination: 'GMP', + totalBookings: 2, + wagons: [ + { + sequenceNo: 1, + wagonNumber: 'W-ICY', + boardYard: 'Dire Dawa Port', + alightYard: null, + allocations: [ + { + ...loadedAllocation, + containerItems: [{ containerNumber: 'ICY-001' }], + }, + ], + }, + { + sequenceNo: 2, + wagonNumber: 'W-IMP', + boardYard: null, + alightYard: null, + allocations: [ + { + ...loadedAllocation, + containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }], + }, + ], + }, + ], + operation: { status: {} }, + }; + + const html = (service as never as { + buildImportLoadListHtml: (l: unknown) => string; + }).buildImportLoadListHtml(loadList); + + expect(html).toContain('TO BE LOADED AT DIRE DAWA PORT'); + // Departure station of the leg slot is its board yard, not the origin. + expect(html).toContain('Dire Dawa Port'); + // Only the origin-loaded container counts; the leg slot's tallies separately. + expect(html).toContain('Total containers1'); + expect(html).toContain('To load en route1 containers'); + }); + + it('marks a leg slot on the export document as TO LOAD AT its board yard and keeps it out of the tallies', () => { + const sizedAllocation = { + ...loadedAllocation, + containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }], + }; + const legWagon = { ...makeWagon(2, 'W-LEG', [sizedAllocation]), id: 'slot-leg' }; + const schedule = { + id: 'schedule-1', + trainNumber: '8302', + direction: 'EXPORT', + trainSet: { wagons: [{ ...makeWagon(1, 'W-001', [sizedAllocation]), id: 'slot-1' }, legWagon] }, + scheduleBookings: [], + }; + + const html = (service as never as { + buildExportLoadListHtml: (s: unknown, o?: unknown) => string; + }).buildExportLoadListHtml(schedule, { + pendingBoardYardLabelBySlot: new Map([['slot-leg', 'Dire Dawa Port']]), + }); + + expect(html).toContain('TO LOAD AT DIRE DAWA PORT'); + expect(html).toContain('Total containers1'); + expect(html).toContain('To load en route1 containers'); + }); + + it('prints coupled/switched wagons logged at this stop, and omits the box when there are none', () => { + const schedule = { + id: 'schedule-1', + trainNumber: '8302', + direction: 'EXPORT', + trainSet: { wagons: [{ ...makeWagon(1, 'W-001', [loadedAllocation]), id: 'slot-1' }] }, + scheduleBookings: [], + }; + const build = (service as never as { + buildExportLoadListHtml: (s: unknown, o?: unknown) => string; + }).buildExportLoadListHtml.bind(service); + + const withChanges = build(schedule, { + consistChangesAtStop: [ + { action: 'ADD', wagonNumber: 'W-1002' }, + { action: 'SWITCH', wagonNumber: 'W-0501 → W-1003' }, + ], + }); + expect(withChanges).toContain('Consist changed at this stop'); + expect(withChanges).toContain('Coupled: W-1002'); + expect(withChanges).toContain('Uncoupled — replaced: W-0501 → W-1003'); + + const withoutChanges = build(schedule, {}); + expect(withoutChanges).not.toContain('Consist changed at this stop'); + }); + it('lists loaded empty containers by number and states they are empty', () => { const schedule = { id: 'schedule-1', @@ -1264,6 +1365,25 @@ describe('TrainSchedulingService', () => { expect(html).toContain('2 (1 empty)'); }); + it('keeps whole-route cargo whose allocation never left PLANNED (import flow) on board', () => { + // The import flow confirms loading at schedule level and never flips the + // allocation to LOADED — the cargo is still on the train until DEPARTED. + const schedule = { + trainSet: { + wagons: [ + { ...makeWagon(1, 'W-IMP', [allocWith({ status: 'PLANNED' })]), status: 'RESERVED' }, + { ...makeWagon(2, 'W-ICY', [allocWith({ status: 'LOADED', bookingId: 'booking-2' })]), status: 'RESERVED', boardYardId: 'yard-mid' }, + ], + }, + scheduleBookings: [], + }; + + const { wagons } = onBoardView(schedule); + const byNumber = wagons as Array<{ physicalWagon: { wagonNumber: string }; allocations: unknown[] }>; + expect(byNumber.map((w) => w.physicalWagon.wagonNumber)).toEqual(['W-IMP', 'W-ICY']); + expect(byNumber[0].allocations).toHaveLength(1); + }); + it('hides a leg slot (boardYardId set) until it has confirmed LOADED cargo', () => { const legWagonEmpty = { ...makeWagon(2, 'W-LEG', [allocWith({ status: 'RESERVED' })]), status: 'RESERVED', boardYardId: 'yard-mid' }; const legWagonLoaded = { ...makeWagon(3, 'W-LEG2', [allocWith({ status: 'LOADED' })]), status: 'RESERVED', boardYardId: 'yard-mid' }; 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 9cec10ff7..71153c547 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -55,7 +55,10 @@ import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity' import { TrainSet } from '../../train-sets/entities/train-set.entity'; import { ScheduleWagonAdjustmentLog } from '../../train-schedules/entities/schedule-wagon-adjustment-log.entity'; import { TrainScheduleBooking } from '../../train-schedules/entities/train-schedule-booking.entity'; -import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; +import { + TrainSchedule, + type StationWorkPhaseLog, +} from '../../train-schedules/entities/train-schedule.entity'; import { WagonAllocationContainerItem } from '../../train-schedules/entities/wagon-allocation-container-item.entity'; import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity'; import { TrainScheduleBookingsRepository } from '../../train-schedules/train-schedule-bookings.repository'; @@ -2899,23 +2902,19 @@ export class TrainSchedulingService { schedule = reloaded; } } - // Loading is tracked per station: dispatching with cargo still to board at - // the origin marks it loaded (checklist + auto-load below), so the origin's - // loading time window must have been started first — same gate the - // per-booking load endpoint enforces. - const originBoarders = await this.unloadedOriginBoarderIds( - scheduleId, - schedule.originStationId, - ); - const boardersToLoad = dto.loadedBookingIds - ? originBoarders.filter((id) => new Set(dto.loadedBookingIds).has(id)) - : originBoarders; - if ( - boardersToLoad.length && - !schedule.stationWorkLogs?.[schedule.originStationId]?.loading?.startedAt - ) { + // Dispatch requires the origin's loading window to be COMPLETE: started + // and ended. Not started or still open both block — a train departs only + // after loading was formally opened and closed. + const originLoadingLog = + schedule.stationWorkLogs?.[schedule.originStationId]?.loading; + if (!originLoadingLog?.startedAt) { throw new BadRequestException( - 'Start loading at the origin station before dispatching with cargo to load', + 'Start (and end) the loading window at the origin station before dispatching', + ); + } + if (!originLoadingLog?.endedAt) { + throw new BadRequestException( + 'End the loading window at the origin station before dispatching', ); } // Staff may record the departure after the fact — past is fine, future is not. @@ -2963,7 +2962,7 @@ export class TrainSchedulingService { actualDepartureAt: now, trainNumber, // Freeze the wagon plan the moment the train leaves the editable phase. - wagonAllocationSnapshot: this.buildWagonAllocationSnapshot( + wagonAllocationSnapshot: await this.buildWagonAllocationSnapshot( schedule, TrainScheduleStatusEnum.Dispatched, now, @@ -3398,6 +3397,11 @@ export class TrainSchedulingService { const schedule = await this.getImportDjiboutiSchedule(scheduleId); const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); const generatedAt = operation.loadListGeneratedAt ?? new Date(); + // Leg slots (boardYardId set) couple to the train mid-corridor — this + // Djibouti-side document must say where, not list their cargo as loaded here. + const slotYardLabels = await this.yardLabelsById( + (schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]), + ); await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, { loadListGeneratedAt: generatedAt, @@ -3424,6 +3428,8 @@ export class TrainSchedulingService { wagonType: wagon.wagonType?.code ?? wagon.wagonType?.name ?? null, tareWeightTons: wagon.wagonType?.tareWeightTons ?? null, equatedLengthM: wagon.wagonType?.equatedLengthM ?? null, + boardYard: wagon.boardYardId ? (slotYardLabels.get(wagon.boardYardId) ?? 'en route') : null, + alightYard: wagon.alightYardId ? (slotYardLabels.get(wagon.alightYardId) ?? 'en route') : null, allocations: (wagon.allocations ?? []).map((allocation) => ({ bookingId: allocation.bookingId, bookingReference: allocation.booking?.reference ?? null, @@ -3464,7 +3470,19 @@ export class TrainSchedulingService { throw new BadRequestException('Export marshalling document applies only to EXPORT schedules'); } + // Leg slots couple mid-corridor — this origin document must say where their + // cargo boards instead of listing it as loaded here (see the import list). + const slotYardLabels = await this.yardLabelsById( + (schedule.trainSet?.wagons ?? []).map((wagon) => wagon.boardYardId), + ); + const pendingBoardYardLabelBySlot = new Map( + (schedule.trainSet?.wagons ?? []) + .filter((wagon) => wagon.boardYardId) + .map((wagon) => [wagon.id, slotYardLabels.get(wagon.boardYardId!) ?? 'en route']), + ); + const html = this.buildExportLoadListHtml(schedule, { + pendingBoardYardLabelBySlot, emptyContainers: await this.loadedEmptyContainers(scheduleId), logoImageUrl: await this.logoSettings.getLogoImageUrl(), }); @@ -3482,8 +3500,11 @@ export class TrainSchedulingService { * intercity marshalling ("Marshalling 2") document printed after mid-corridor * station work. A wagon slot is on the train iff it has not DEPARTED and * either rides the whole corridor (no boardYardId) or has confirmed LOADED - * cargo. Kept wagons carry only their LOADED allocations (DEPARTED = - * unloaded, PLANNED/RESERVED = not on board yet). + * cargo. Whole-route cargo counts as on board unless DEPARTED (unloaded) — + * the import flow confirms loading at schedule level and never flips the + * allocation to LOADED, so requiring LOADED here rendered every import wagon + * as EMPTY. Leg slots (boardYardId set, coupled mid-corridor) still require + * confirmed LOADED cargo before they appear. * ponytail: boardYardId presence is the "boarded yet?" heuristic; upgrade * path is comparing the board yard against the latest checkpoint sequence. */ @@ -3499,7 +3520,9 @@ export class TrainSchedulingService { }) .map((wagon) => ({ ...wagon, - allocations: (wagon.allocations ?? []).filter((a) => a.status === 'LOADED'), + allocations: (wagon.allocations ?? []).filter((a) => + wagon.boardYardId == null ? a.status !== 'DEPARTED' : a.status === 'LOADED', + ), })) as TrainSetWagon[]; const onBoardBookingIds = new Set( @@ -3534,6 +3557,16 @@ export class TrainSchedulingService { : `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? 'origin'} — no checkpoint recorded`; const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule); + // Couples/switches logged AT THIS STOP — what staff standing here actually + // just did to the consist. Bare trims (REMOVE, no replacement) are left + // out: nothing new to point staff at for those. Origin adjustments (a + // different yard) don't show up on this stop's document. + const consistChangesAtStop = last + ? await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({ + where: { trainScheduleId: scheduleId, yardId: last.yardId, action: In(['ADD', 'SWITCH']) }, + order: { occurredAt: 'DESC' }, + }) + : []; const html = this.buildExportLoadListHtml(schedule, { title: 'Intercity Marshalling Document / Load List (Marshalling 2)', positionLabel, @@ -3541,6 +3574,7 @@ export class TrainSchedulingService { unassignedBookings, emptyContainers: await this.loadedEmptyContainers(scheduleId), logoImageUrl: await this.logoSettings.getLogoImageUrl(), + consistChangesAtStop, }); // Styled table-aware fallback (marshalling grid) — see importLoadListDocument. const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list'); @@ -3589,6 +3623,15 @@ export class TrainSchedulingService { .find({ where: { trainScheduleId: scheduleId } }); } + private async yardLabelsById( + ids: Array, + ): Promise> { + const unique = [...new Set(ids.filter((id): id is string => Boolean(id)))]; + if (!unique.length) return new Map(); + const yards = await this.dataSource.getRepository(Yard).find({ where: { id: In(unique) } }); + return new Map(yards.map((yard) => [yard.id, yard.label || yard.code])); + } + private buildExportLoadListHtml( schedule: TrainSchedule, opts?: { @@ -3598,6 +3641,13 @@ export class TrainSchedulingService { unassignedBookings?: Booking[]; emptyContainers?: EmptyContainerReturn[]; logoImageUrl?: string | null; + // Slots that couple to the train downstream (slot id → board yard label). + // Their cargo renders as TO LOAD AT and stays out of the loaded tallies. + pendingBoardYardLabelBySlot?: Map; + // Intercity (Marshalling 2) only: couples/switches logged at the stop + // this document is printed at (see ScheduleWagonAdjustmentLog). Origin + // import/export docs never pass this, so they render no such box. + consistChangesAtStop?: ScheduleWagonAdjustmentLog[]; }, ): string { const esc = (value: unknown) => @@ -3661,6 +3711,7 @@ export class TrainSchedulingService { `, ]; } + const pendingAt = opts?.pendingBoardYardLabelBySlot?.get(wagon.id); return allocations.map((allocation) => { const booking = allocation.booking ?? bookingById.get(allocation.bookingId); const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType; @@ -3672,7 +3723,7 @@ export class TrainSchedulingService { const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', '); return ` ${wagonCells} - ${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)} + ${pendingAt ? `TO LOAD AT ${esc(pendingAt).toUpperCase()} — ` : ''}${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)} ${esc(companyName)} ${esc(containerNumbers || firstContainer?.containerNumber)} ${esc(chassisNumbers)} @@ -3709,18 +3760,27 @@ export class TrainSchedulingService { (wagon.allocations ?? []).length === 0 && !emptiesByWagon.get(Number(wagon.sequenceNo))?.length, ).length; + const loadsHere = (wagon: TrainSetWagon) => !opts?.pendingBoardYardLabelBySlot?.get(wagon.id); const totalWeight = wagons.reduce( (sum, wagon) => - sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0), + sum + + (loadsHere(wagon) + ? (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0) + : 0), 0, ); // Container count summary (40ft, 20ft) — empties returning to Djibouti are // physically on the train, so they count, and are called out on their own tile. - let count40ft = 0, count20ft = 0; + // Cargo boarding downstream is not on this train yet — it tallies separately. + let count40ft = 0, count20ft = 0, pendingContainers = 0; wagons.forEach((wagon) => { (wagon.allocations ?? []).forEach((allocation) => { (allocation.containerItems ?? []).forEach((item) => { + if (!loadsHere(wagon)) { + pendingContainers++; + return; + } const size = this.resolveContainerItemSize(item); if (size === 40) count40ft++; else if (size === 20) count20ft++; @@ -3787,6 +3847,7 @@ export class TrainSchedulingService {
Containers 40ft${esc(count40ft)}
Containers 20ft${esc(count20ft)}
Total containers${esc(count40ft + count20ft)}
+ ${pendingContainers ? `
To load en route${esc(pendingContainers)} containers
` : ''} ${emptyContainers.length ? `
Empty containers${esc(emptyContainers.length)}
` : ''}
Prepared person${esc(schedule.preparedByUserId)}
Check person${esc(schedule.checkedByUserId)}
@@ -3797,6 +3858,24 @@ export class TrainSchedulingService { ${opts?.positionLabel ? `
Current position${esc(opts.positionLabel)}
` : ''} + ${ + opts?.consistChangesAtStop?.length + ? `
+ Consist changed at this stop: + ${(() => { + const coupled = opts.consistChangesAtStop.filter((row) => row.action === 'ADD'); + const switched = opts.consistChangesAtStop.filter((row) => row.action === 'SWITCH'); + return [ + coupled.length ? `Coupled: ${esc(coupled.map((row) => row.wagonNumber).join(', '))}` : '', + switched.length ? `Uncoupled — replaced: ${esc(switched.map((row) => row.wagonNumber).join(', '))}` : '', + ] + .filter(Boolean) + .join('  |  '); + })()} +
` + : '' + } + @@ -3960,19 +4039,30 @@ export class TrainSchedulingService { .replace(/'/g, '''); const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleString('en-GB') : '-'); const status = loadList.operation.status; + // A leg slot (boardYard set) couples mid-corridor — its cargo is NOT on the + // physical train this Djibouti-side document is checked against, so it must + // stay out of the loaded tallies or the gate count stops matching. + const loadsHere = (wagon: (typeof loadList.wagons)[number]) => !wagon.boardYard; const totalAllocations = loadList.wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0); const totalWeight = loadList.wagons.reduce( (sum, wagon) => - sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0), + sum + + (loadsHere(wagon) + ? wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0) + : 0), 0, ); const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length; - // Container count summary (40ft, 20ft) - let count40ft = 0, count20ft = 0; + // Container count summary (40ft, 20ft) — loaded at origin vs. en route + let count40ft = 0, count20ft = 0, pendingContainers = 0; loadList.wagons.forEach((wagon) => { wagon.allocations.forEach((allocation) => { (allocation.containerItems ?? []).forEach((item) => { + if (!loadsHere(wagon)) { + pendingContainers++; + return; + } const size = this.resolveContainerItemSize(item); if (size === 40) count40ft++; else if (size === 20) count20ft++; @@ -3987,8 +4077,8 @@ export class TrainSchedulingService { - - `; + + `; // An empty wagon still runs in the consist, so it still gets a line — see // buildExportLoadListHtml. if (wagon.allocations.length === 0) { @@ -4013,7 +4103,7 @@ export class TrainSchedulingService { - + `; }, @@ -4086,6 +4176,7 @@ export class TrainSchedulingService {
Containers 40ft${esc(count40ft)}
Containers 20ft${esc(count20ft)}
Total containers${esc(count40ft + count20ft)}
+ ${pendingContainers ? `
To load en route${esc(pendingContainers)} containers
` : ''}
Gatepass granted${esc(date(loadList.operation.gatepassGrantedAt))}
@@ -4430,6 +4521,48 @@ export class TrainSchedulingService { } /** Track payload for a schedule: ordered stations, logged checkpoints, current position. */ + /** Attach `startedByName` / `endedByName` to each work-window phase (one iam lookup). */ + private async stationWorkLogsWithNames( + logs: TrainSchedule['stationWorkLogs'], + ): Promise> { + const workLogs = logs ?? {}; + const userIds = [ + ...new Set( + Object.values(workLogs) + .flatMap((log) => [ + log.loading?.startedByUserId, + log.loading?.endedByUserId, + log.unloading?.startedByUserId, + log.unloading?.endedByUserId, + ]) + .filter((id): id is string => Boolean(id)), + ), + ]; + const rows: Array<{ id: string; name: string | null }> = userIds.length + ? await this.dataSource.query( + `SELECT id, COALESCE(username, email) AS name FROM iam.users WHERE id = ANY($1::uuid[])`, + [userIds], + ) + : []; + const nameById = new Map(rows.map((r) => [r.id, r.name])); + const withNames = (phase?: StationWorkPhaseLog) => + phase + ? { + ...phase, + startedByName: phase.startedByUserId + ? nameById.get(phase.startedByUserId) ?? null + : null, + endedByName: phase.endedByUserId ? nameById.get(phase.endedByUserId) ?? null : null, + } + : undefined; + return Object.fromEntries( + Object.entries(workLogs).map(([yardId, log]) => [ + yardId, + { loading: withNames(log.loading), unloading: withNames(log.unloading) }, + ]), + ); + } + async getScheduleCheckpoints(scheduleId: string) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { @@ -4471,8 +4604,9 @@ export class TrainSchedulingService { destination: stations[stations.length - 1]?.label ?? null, stations, // Per-yard loading/unloading time windows for the track page's - // start/end buttons and elapsed-time display. - stationWorkLogs: schedule.stationWorkLogs ?? {}, + // start/end buttons and elapsed-time display — with the recorder's + // display name resolved so staff see WHO started/ended each window. + stationWorkLogs: await this.stationWorkLogsWithNames(schedule.stationWorkLogs), currentSequenceNo, checkpoints: events.map((e) => ({ id: e.id, @@ -4947,23 +5081,15 @@ export class TrainSchedulingService { if (schedule.status !== TrainScheduleStatusEnum.Dispatched) { throw new BadRequestException('Only DISPATCHED trains can arrive'); } - // Arrival bulk-marks every booking destined for the final yard as arrived - // (autoArriveAtFinalYard) — unloading is tracked per station, so the - // destination's unloading time window must be started before that sweep - // may run. Skipped when nothing on the train alights at the final yard. - const alightsAtFinal = (schedule.scheduleBookings ?? []).some( - (sb) => - sb.booking?.destinationYardId === schedule.destinationStationId && - sb.booking?.status === 'IN_TRANSIT', + // Arrival happens BEFORE unloading: the train is marked arrived whenever + // it physically gets there, and the destination's unloading window opens + // afterwards. The bulk booking sweep (autoArriveAtFinalYard) only runs + // when that window is already open — otherwise final-yard bookings stay + // IN_TRANSIT and are unloaded per booking once staff start unloading + // (the per-booking endpoint enforces the window itself). + const destinationUnloadingStarted = Boolean( + schedule.stationWorkLogs?.[schedule.destinationStationId]?.unloading?.startedAt, ); - if ( - alightsAtFinal && - !schedule.stationWorkLogs?.[schedule.destinationStationId]?.unloading?.startedAt - ) { - throw new BadRequestException( - 'Start unloading at the destination station before marking the train arrived', - ); - } // The arrival clock: the operator's entered time when arriving via the final // checkpoint (already order/future-checked there), else now. @@ -4976,7 +5102,7 @@ export class TrainSchedulingService { { actualArrivalAt: now, // Freeze the plan before the wagons below are released to their yards. - wagonAllocationSnapshot: this.buildWagonAllocationSnapshot( + wagonAllocationSnapshot: await this.buildWagonAllocationSnapshot( schedule, TrainScheduleStatusEnum.Arrived, now, @@ -5004,7 +5130,12 @@ export class TrainSchedulingService { // operator didn't unload individually get their arrival stamped now as a // bulk fallback. Mid-corridor bookings are NOT touched — their arrival is // their own unload (possibly already done while the train kept rolling). - await this.bookingJourneyService.autoArriveAtFinalYard(manager, schedule, now); + // Runs only when the destination's unloading window is already open — + // otherwise arrival precedes unloading and staff unload per booking + // after starting the window. + if (destinationUnloadingStarted) { + await this.bookingJourneyService.autoArriveAtFinalYard(manager, schedule, now); + } // Release every locomotive of the set (not just the legacy primary) and move it // to the destination yard where it physically arrived. @@ -5350,7 +5481,7 @@ export class TrainSchedulingService { bookingWindowStatus: 'CLOSED', windowPhase: 'DONE', // Freeze the plan before the wagons below are released back to the yard. - wagonAllocationSnapshot: this.buildWagonAllocationSnapshot( + wagonAllocationSnapshot: await this.buildWagonAllocationSnapshot( schedule, TrainScheduleStatusEnum.Cancelled, now, @@ -6137,11 +6268,11 @@ export class TrainSchedulingService { * physical wagons, so the historical allocation survives those wagons being * re-pinned onto later trains. `capturedStatus` is the status being applied. */ - private buildWagonAllocationSnapshot( + private async buildWagonAllocationSnapshot( schedule: TrainSchedule, capturedStatus: TrainScheduleStatusEnum, capturedAt: Date, - ): WagonAllocationSnapshot { + ): Promise { const slots = [...(schedule.trainSet?.wagons ?? [])] .sort((a, b) => a.sequenceNo - b.sequenceNo) .map((wagon) => ({ @@ -6165,10 +6296,41 @@ export class TrainSchedulingService { })), })); + // A built train hauls EVERY coupled wagon, empties included. After this + // transition the physical wagons are released and re-pinned to later + // trains, so capture the empty consist here — it is the only durable + // record of which empties rode this departure (history + yard tracking). + const coveredPhysicalIds = new Set( + slots.map((slot) => slot.physicalWagonId).filter(Boolean), + ); + const trainWagons = schedule.trainSet?.trainId + ? await this.dataSource.getRepository(Wagon).find({ + where: { trainId: schedule.trainSet.trainId }, + relations: { wagonType: true }, + order: { sequenceNumber: 'ASC' }, + }) + : []; + const emptyConsistWagons = trainWagons + .filter((wagon) => !coveredPhysicalIds.has(wagon.id)) + .map((wagon, index) => ({ + physicalWagonId: wagon.id, + physicalWagonNumber: wagon.wagonNumber ?? null, + sequenceNo: wagon.sequenceNumber ?? slots.length + index + 1, + wagonTypeId: wagon.wagonTypeId ?? null, + wagonTypeCode: wagon.wagonType?.code ?? null, + wagonTypeName: wagon.wagonType?.name ?? null, + capacityTons: Number(wagon.wagonType?.capacityTons ?? 0), + tareWeightTons: wagon.wagonType + ? Number(wagon.wagonType.tareWeightTons) + : null, + lengthMeters: Number(wagon.wagonType?.lengthMeters ?? 0), + })); + return { capturedStatus, capturedAt: capturedAt.toISOString(), slots, + emptyConsistWagons, }; } @@ -9638,7 +9800,36 @@ export class TrainSchedulingService { 0, ...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo), ); - const emptyConsistWagons = rawConsistWagons + // Frozen schedules: the live wagon↔train joins no longer describe this + // departure, so the empty consist is read from the snapshot captured at + // dispatch/arrival — that keeps "which wagons ran empty" in the history + // views. Snapshots from before empties were recorded simply have none. + const frozenEmptyConsistWagons = (snapshot?.emptyConsistWagons ?? []).map( + (wagon) => ({ + id: wagon.physicalWagonId, + sequenceNo: wagon.sequenceNo, + capacityTons: roundTons(wagon.capacityTons), + lengthMeters: roundTons(wagon.lengthMeters), + assignedWeightTons: 0, + tareWeightTons: + wagon.tareWeightTons != null ? roundTons(wagon.tareWeightTons) : null, + status: 'EMPTY', + boardYardId: null, + alightYardId: null, + physicalWagonId: wagon.physicalWagonId, + physicalWagonNumber: wagon.physicalWagonNumber, + wagonType: wagon.wagonTypeId + ? { + id: wagon.wagonTypeId, + code: wagon.wagonTypeCode ?? '', + name: wagon.wagonTypeName ?? '', + } + : null, + allocations: [], + consistOnly: true, + }), + ); + const liveEmptyConsistWagons = rawConsistWagons .filter((wagon) => !coveredPhysicalIds.has(wagon.id)) .map((wagon, index) => ({ // Physical wagon id — there is no TrainSetWagon slot behind this @@ -9671,6 +9862,9 @@ export class TrainSchedulingService { allocations: [], consistOnly: true, })); + const emptyConsistWagons = isWagonAllocationFrozen + ? frozenEmptyConsistWagons + : liveEmptyConsistWagons; // The consist is DRAWN in the built train's real coupling order (rawConsistWagons // is already ASC/DESC per reverseWagonOrder), not in slot order — see diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/DoCollectionDateFields.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/DoCollectionDateFields.tsx index 365b8e404..ecc2a6104 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/DoCollectionDateFields.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/DoCollectionDateFields.tsx @@ -36,6 +36,13 @@ export function DoCollectionDateFields({ const outOfOrder = Boolean(value.vesselArrival && value.doCollected) && !doDatesComplete(value); + const today = new Date(); + today.setHours(0, 0, 0, 0); + const doMin = + value.vesselArrival && value.vesselArrival > today + ? value.vesselArrival + : today; + return ( onChange({ ...value, vesselArrival: v ? new Date(v) : null }) } - maxDate={new Date()} + minDate={today} size="sm" required withAsterisk @@ -57,8 +64,7 @@ export function DoCollectionDateFields({ onChange={(v) => onChange({ ...value, doCollected: v ? new Date(v) : null }) } - minDate={value.vesselArrival ?? undefined} - maxDate={new Date()} + minDate={doMin} size="sm" required withAsterisk diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index ff59b1b65..2e31d2a8c 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -2055,9 +2055,10 @@ export default function GlCreateBookingForm() { ? bulkErrors.quantity : undefined } - onChange={(e) => - setBulk((b) => ({ ...b, cargoWeightTons: e.currentTarget.value })) - } + onChange={(e) => { + const value = e.currentTarget.value; + setBulk((b) => ({ ...b, cargoWeightTons: value })); + }} radius={10} styles={fieldStyles} /> @@ -2074,9 +2075,10 @@ export default function GlCreateBookingForm() { ? bulkErrors.quantity : undefined } - onChange={(e) => - setBulk((b) => ({ ...b, itemCount: e.currentTarget.value })) - } + onChange={(e) => { + const value = e.currentTarget.value; + setBulk((b) => ({ ...b, itemCount: value })); + }} radius={10} styles={fieldStyles} /> @@ -2091,12 +2093,10 @@ export default function GlCreateBookingForm() { step={1} value={bulk.requestedWagons} error={showErrors ? bulkErrors.wagons : undefined} - onChange={(e) => - setBulk((b) => ({ - ...b, - requestedWagons: e.currentTarget.value, - })) - } + onChange={(e) => { + const value = e.currentTarget.value; + setBulk((b) => ({ ...b, requestedWagons: value })); + }} radius={10} styles={fieldStyles} /> @@ -2110,12 +2110,10 @@ export default function GlCreateBookingForm() { step={1} value={bulk.hazardousQuantity} error={showErrors ? bulkErrors.hazardous : undefined} - onChange={(e) => - setBulk((b) => ({ - ...b, - hazardousQuantity: e.currentTarget.value, - })) - } + onChange={(e) => { + const value = e.currentTarget.value; + setBulk((b) => ({ ...b, hazardousQuantity: value })); + }} radius={10} styles={fieldStyles} /> @@ -2129,12 +2127,10 @@ export default function GlCreateBookingForm() { step={1} value={bulk.reeferQuantity} error={showErrors ? bulkErrors.reefer : undefined} - onChange={(e) => - setBulk((b) => ({ - ...b, - reeferQuantity: e.currentTarget.value, - })) - } + onChange={(e) => { + const value = e.currentTarget.value; + setBulk((b) => ({ ...b, reeferQuantity: value })); + }} radius={10} styles={fieldStyles} /> diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/CheckpointTimeModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/CheckpointTimeModal.tsx index c024cb0e9..1f8f915de 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/CheckpointTimeModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/CheckpointTimeModal.tsx @@ -1,4 +1,4 @@ -import { Button, Divider, Group, Modal, SimpleGrid, Stack, Text, Textarea } from "@mantine/core"; +import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core"; import { DateTimePicker } from "@mantine/dates"; import { useMediaQuery } from "@mantine/hooks"; import { useEffect, useState } from "react"; @@ -67,6 +67,10 @@ export function CheckpointTimeModal({ const isSmallScreen = useMediaQuery("(max-width: 48em)"); const [at, setAt] = useState(null); const [note, setNote] = useState(""); + // The four station-work stamps are no longer edited HERE — the track page's + // "Loading & unloading windows" section owns start/end with its own + // permissions. The modal still carries any existing stamps through + // unchanged on submit, so editing a checkpoint never wipes them. const [handling, setHandling] = useState(EMPTY_HANDLING); useEffect(() => { if (!opened) return; @@ -121,34 +125,6 @@ export function CheckpointTimeModal({ radius="md" /> - - - Loading and unloading times for this stop. Total handling is unloading start to - loading finish; the rest of the stay reports as other activity. - - - {HANDLING_FIELDS.map(([field, label]) => ( - - setHandling((prev) => ({ ...prev, [field]: v ? new Date(v) : null })) - } - maxDate={new Date()} - dropdownType={isSmallScreen ? "modal" : "popover"} - popoverProps={{ withinPortal: true }} - valueFormat="DD MMM YYYY HH:mm" - clearable - radius="md" - /> - ))} - -
${esc(wagon.wagonType)} ${wagon.tareWeightTons == null ? '-' : esc(Number(wagon.tareWeightTons).toFixed(2))} ${wagon.equatedLengthM == null ? '-' : esc(Number(wagon.equatedLengthM).toFixed(3))}${esc(loadList.origin)}${esc(loadList.destination)}${esc(wagon.boardYard ?? loadList.origin)}${esc(wagon.alightYard ?? loadList.destination)}${esc(allocation.loadType)} ${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')} ${esc(sealNumbers || '-')}${wagon.boardYard ? `TO BE LOADED AT ${esc(wagon.boardYard).toUpperCase()}` : ''} ${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}