Merge pull request #1424 from Tria-plc/dev

dev
This commit is contained in:
Nathnael Wondisha
2026-08-27 08:32:48 +03:00
committed by GitHub
37 changed files with 979 additions and 163 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 673 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

View File

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

View File

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

View File

@@ -34,10 +34,6 @@ import {
directionScopeSql,
} from "../user-trade-access/trade-scope.util";
/** Bookings carry a contract_kind column; GENERAL = umbrella contract row, not a shipment. */
const EXCLUDE_GENERAL_CONTRACT_BOOKINGS =
"(booking.contract_kind IS NULL OR booking.contract_kind <> 'GENERAL')";
export type OverviewBookingKpisRow = {
total: number;
totalActive: number;
@@ -147,7 +143,6 @@ export class OverviewRepository {
"submittedToday",
)
.where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere(scope.sql, scope.params)
.setParameters({
closedStatuses: [...OVERVIEW_CLOSED_STATUSES],
@@ -337,7 +332,6 @@ export class OverviewRepository {
.select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, "date")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere(scope.sql, scope.params)
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
.groupBy("booking.created_at::date")
@@ -357,7 +351,6 @@ export class OverviewRepository {
.select("booking.status", "status")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere(scope.sql, scope.params)
.groupBy("booking.status")
.getRawMany<{ status: string; count: string }>();
@@ -427,7 +420,6 @@ export class OverviewRepository {
.addSelect("booking.payment_currency", "paymentCurrency")
.addSelect("booking.created_at", "createdAt")
.where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere(scope.sql, scope.params)
.orderBy("booking.created_at", "DESC")
.limit(limit)
@@ -463,7 +455,6 @@ export class OverviewRepository {
.select("booking.freight_type", "label")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere("booking.status != 'DRAFT'")
.andWhere(scope.sql, scope.params)
.groupBy("booking.freight_type")
@@ -485,7 +476,6 @@ export class OverviewRepository {
.select("booking.payment_currency", "label")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere("booking.status != 'DRAFT'")
.andWhere(scope.sql, scope.params)
.groupBy("booking.payment_currency")
@@ -602,7 +592,6 @@ export class OverviewRepository {
this.bookingRepository
.createQueryBuilder("booking")
.where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere(bookingScope.sql, bookingScope.params)
.andWhere(windowSql("booking.created_at"), { days, offsetDays })
.getCount(),
@@ -802,7 +791,6 @@ export class OverviewRepository {
.addSelect("FLOOR(EXTRACT(HOUR FROM booking.created_at) / 3)::int", "block")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere(scope.sql, scope.params)
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
.groupBy("EXTRACT(ISODOW FROM booking.created_at)::int")
@@ -1457,7 +1445,6 @@ export class OverviewRepository {
ON y.id = CASE WHEN b.trade_direction = 'EXPORT'
THEN b.destination_yard_id ELSE b.origin_yard_id END
WHERE b.deleted_at IS NULL
AND (b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')
AND b.created_at >= NOW() - make_interval(days => $1::int)
GROUP BY 1
ORDER BY count DESC
@@ -1475,7 +1462,6 @@ export class OverviewRepository {
ON y.id = CASE WHEN b.trade_direction = 'EXPORT'
THEN b.destination_yard_id ELSE b.origin_yard_id END
WHERE b.deleted_at IS NULL
AND (b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')
AND b.created_at >= NOW() - make_interval(days => $1::int)
GROUP BY 1, 2
ORDER BY 1, 2

View File

@@ -2,8 +2,10 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Company } from '../../companies/entities/company.entity';
import { Invoice } from '../../billing/entities/invoice.entity';
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util';
import { ReportContext, ReportDefinition } from '../report.types';
import { CURRENCY_FILTER, PAYER_EXPR, currencyOf } from '../revenue-classification';
const OPEN_STATUSES = ['ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE'];
@@ -13,13 +15,22 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
// to now() in SQL when the filter is unset (see the COALESCE below).
const asOf = (params.asOf as string | null) ?? null;
// Both payer joins are LEFT: an invoice billed to a shipping line carries no
// company, and an INNER join on `companies` silently drops its balance out of
// the arrears total.
const qb = ctx.ds
.createQueryBuilder()
.from(Invoice, 'i')
.innerJoin(Company, 'c', 'c.id = i.company_id')
.leftJoin(Company, 'c', 'c.id = i.company_id')
.leftJoin(ShippingLineCompany, 'slc', 'slc.id = i.shipping_line_company_id')
.where('i.deleted_at IS NULL')
.andWhere('i.status IN (:...openStatuses)', { openStatuses: OPEN_STATUSES })
.andWhere('i.balance_amount > 0')
// Stored casing has drifted ("usd" rows exist), and one arrears figure
// cannot span two currencies.
.andWhere('UPPER(i.currency) = :currency', {
currency: currencyOf(params).toUpperCase(),
})
.setParameter('asOf', asOf);
// ACL: invoices.source_id is a varchar pointer at the originating booking.
@@ -32,9 +43,15 @@ export const agingReceivablesReport: ReportDefinition = {
title: 'Aging Receivables',
description: 'Outstanding customer balances bucketed by days overdue',
group: 'Finance',
filters: [{ key: 'asOf', label: 'As of', type: 'date' }],
filters: [{ key: 'asOf', label: 'As of', type: 'date' }, CURRENCY_FILTER],
columns: [
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
{
key: 'customer',
label: 'Customer',
type: 'string',
sortable: true,
sortExpr: PAYER_EXPR,
},
{ key: 'invoices', label: 'Invoices', type: 'number' },
{ key: 'outstanding', label: 'Outstanding', type: 'money', sortable: true },
{ key: 'current', label: 'Current', type: 'money' },
@@ -46,7 +63,7 @@ export const agingReceivablesReport: ReportDefinition = {
defaultSort: { key: 'outstanding', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('c.name', 'customer')
.select(PAYER_EXPR, 'customer')
.addSelect('COUNT(*)::int', 'invoices')
.addSelect('ROUND(SUM(i.balance_amount))::float8', 'outstanding')
.addSelect(
@@ -72,15 +89,19 @@ export const agingReceivablesReport: ReportDefinition = {
`ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE(:asOf::timestamptz, now()) - interval '90 days'), 0))::float8`,
'overdue90plus',
)
.groupBy('c.name');
.groupBy(PAYER_EXPR);
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'outstanding')
.addSelect('COUNT(DISTINCT c.id)::int', 'customers')
.addSelect(`COUNT(DISTINCT ${PAYER_EXPR})::int`, 'customers')
.getRawOne();
return [
{ label: 'Outstanding', value: Number(row?.outstanding ?? 0), unit: 'ETB' },
{
label: 'Outstanding',
value: Number(row?.outstanding ?? 0),
unit: currencyOf(ctx.params),
},
{ label: 'Customers with balance', value: Number(row?.customers ?? 0) },
];
},

View File

@@ -2,19 +2,34 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Freight } from '@edr/types';
import { Invoice } from '../../billing/entities/invoice.entity';
import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util';
import { ReportContext, ReportDefinition } from '../report.types';
import { CURRENCY_FILTER, currencyOf } from '../revenue-classification';
const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((v) => ({ value: v, label: v }));
const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((v) => ({
value: v,
label: v,
}));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds.createQueryBuilder().from(Invoice, 'i').where('i.deleted_at IS NULL');
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Invoice, 'i')
.where('i.deleted_at IS NULL')
// Both currencies live in this table; one money column cannot hold both.
.andWhere('UPPER(i.currency) = :currency', {
currency: currencyOf(params).toUpperCase(),
});
if (params.dateFrom) qb.andWhere('i.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('i.created_at < :dateTo', { dateTo: params.dateTo });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('i.status IN (:...statuses)', { statuses });
return qb;
// Every other Finance report scopes by the caller's trade directions; without
// it this one reports the value of invoices its reader may not see.
return applyBookingRefDirectionScope(qb, 'i.source_id', directions);
}
export const invoicingPipelineReport: ReportDefinition = {
@@ -24,7 +39,13 @@ export const invoicingPipelineReport: ReportDefinition = {
group: 'Finance',
filters: [
{ key: 'date', label: 'Created', type: 'daterange' },
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
CURRENCY_FILTER,
{
key: 'statuses',
label: 'Status',
type: 'multiselect',
options: STATUS_OPTIONS,
},
],
columns: [
{ key: 'type', label: 'Type', type: 'string', sortable: true },
@@ -52,8 +73,16 @@ export const invoicingPipelineReport: ReportDefinition = {
.getRawOne();
return [
{ label: 'Invoices', value: Number(row?.invoices ?? 0) },
{ label: 'Total value', value: Number(row?.totalAmount ?? 0), unit: 'ETB' },
{ label: 'Outstanding', value: Number(row?.balance ?? 0), unit: 'ETB' },
{
label: 'Total value',
value: Number(row?.totalAmount ?? 0),
unit: currencyOf(ctx.params),
},
{
label: 'Outstanding',
value: Number(row?.balance ?? 0),
unit: currencyOf(ctx.params),
},
];
},
};

View File

@@ -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<ObjectLi
deadInvoiceStatuses: DEAD_INVOICE_STATUSES,
})
.andWhere("i.source <> '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<ObjectLi
deadInvoiceStatuses: DEAD_INVOICE_STATUSES,
})
.andWhere("i.source <> '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<ObjectLi
}
/**
* What the payment gateway actually recorded against this invoice, summed.
* `invoices.payment_id` points at a payment-api intent id rather than a
* `freight.payments` row, so the reliable link is the booking id both sides
* carry.
* What the payment gateway actually recorded against this invoice.
*
* `freight.payments` is keyed by the booking, not the invoice — `ref_id` holds
* the booking id and there is no invoice column — while one booking routinely
* carries several invoices (25 booking ids here back 58 of them). Reading the
* booking's gateway total straight off each invoice therefore hands the same
* money to every sibling: 113M of gateway receipts claimed against 44M of
* recorded settlement, which surfaced as ~89M of variance that does not exist.
*
* So the booking's receipts are apportioned across its invoices by their share
* of what was recorded as settled — the same device as {@link PAID_SHARE}, and
* the only split that makes the report's gateway column sum to the payments
* table. A booking whose invoices record no settlement at all cannot be split
* that way; it falls back to the billed share, so gateway money nobody booked
* still shows up as variance instead of vanishing.
*
* `invoices.payment_id` does resolve to a `freight.payments` row, but only 72
* of 85 successful payments are pointed at by one, so keying on it drops real
* receipts.
*/
export const GATEWAY_PAID = `(
SELECT COALESCE(SUM(p.amount), 0) FROM freight.payments p
WHERE p.ref_id = i.source_id AND p.status = 'success'
(SELECT COALESCE(SUM(p.amount), 0) FROM freight.payments p
WHERE p.ref_id = i.source_id AND p.status = 'success')
* COALESCE(
i.paid_amount / NULLIF((SELECT SUM(i2.paid_amount) FROM freight.invoices i2
WHERE i2.source_id = i.source_id AND i2.deleted_at IS NULL
AND i2.status NOT IN ('DRAFT', 'CANCELLED')), 0),
i.total_amount / NULLIF((SELECT SUM(i2.total_amount) FROM freight.invoices i2
WHERE i2.source_id = i.source_id AND i2.deleted_at IS NULL
AND i2.status NOT IN ('DRAFT', 'CANCELLED')), 0),
0)
)`;
/** The payer, whichever of the two mutually exclusive payer columns is set. */

View File

@@ -1134,6 +1134,107 @@ describe('TrainSchedulingService', () => {
expect(html).toContain('2 (1 empty)');
});
it('marks a leg slot on the import document as TO BE LOADED and keeps it out of the loaded tallies', () => {
const loadList = {
generatedAt: '2026-07-17T08:00:00.000Z',
trainScheduleId: 'schedule-1',
trainNumber: '7002',
route: 'DCT/SGTD → GMP',
origin: 'DCT/SGTD',
destination: 'GMP',
totalBookings: 2,
wagons: [
{
sequenceNo: 1,
wagonNumber: 'W-ICY',
boardYard: 'Dire Dawa Port',
alightYard: null,
allocations: [
{
...loadedAllocation,
containerItems: [{ containerNumber: 'ICY-001' }],
},
],
},
{
sequenceNo: 2,
wagonNumber: 'W-IMP',
boardYard: null,
alightYard: null,
allocations: [
{
...loadedAllocation,
containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }],
},
],
},
],
operation: { status: {} },
};
const html = (service as never as {
buildImportLoadListHtml: (l: unknown) => string;
}).buildImportLoadListHtml(loadList);
expect(html).toContain('TO BE LOADED AT DIRE DAWA PORT');
// Departure station of the leg slot is its board yard, not the origin.
expect(html).toContain('<td>Dire Dawa Port</td>');
// Only the origin-loaded container counts; the leg slot's tallies separately.
expect(html).toContain('<span>Total containers</span><strong>1</strong>');
expect(html).toContain('<span>To load en route</span><strong>1 containers</strong>');
});
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('<span>Total containers</span><strong>1</strong>');
expect(html).toContain('<span>To load en route</span><strong>1 containers</strong>');
});
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' };

View File

@@ -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<string | null | undefined>,
): Promise<Map<string, string>> {
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<string, string>;
// 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 {
</tr>`,
];
}
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 `<tr>
${wagonCells}
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
<td>${pendingAt ? `TO LOAD AT ${esc(pendingAt).toUpperCase()}` : ''}${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
<td>${esc(companyName)}</td>
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
<td>${esc(chassisNumbers)}</td>
@@ -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 {
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
${pendingContainers ? `<div class="tile"><span>To load en route</span><strong>${esc(pendingContainers)} containers</strong></div>` : ''}
${emptyContainers.length ? `<div class="tile"><span>Empty containers</span><strong>${esc(emptyContainers.length)}</strong></div>` : ''}
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
@@ -3797,6 +3858,24 @@ export class TrainSchedulingService {
${opts?.positionLabel ? `<div class="tile"><span>Current position</span><strong>${esc(opts.positionLabel)}</strong></div>` : ''}
</div>
${
opts?.consistChangesAtStop?.length
? `<div class="notice">
<b>Consist changed at this stop:</b>
${(() => {
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(' &nbsp;|&nbsp; ');
})()}
</div>`
: ''
}
<table>
<thead>
<tr>
@@ -3960,19 +4039,30 @@ export class TrainSchedulingService {
.replace(/'/g, '&#39;');
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 {
<td>${esc(wagon.wagonType)}</td>
<td class="num">${wagon.tareWeightTons == null ? '-' : esc(Number(wagon.tareWeightTons).toFixed(2))}</td>
<td class="num">${wagon.equatedLengthM == null ? '-' : esc(Number(wagon.equatedLengthM).toFixed(3))}</td>
<td>${esc(loadList.origin)}</td>
<td>${esc(loadList.destination)}</td>`;
<td>${esc(wagon.boardYard ?? loadList.origin)}</td>
<td>${esc(wagon.alightYard ?? loadList.destination)}</td>`;
// 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 {
<td>${esc(allocation.loadType)}</td>
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
<td>${esc(sealNumbers || '-')}</td>
<td></td>
<td>${wagon.boardYard ? `TO BE LOADED AT ${esc(wagon.boardYard).toUpperCase()}` : ''}</td>
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
</tr>`;
},
@@ -4086,6 +4176,7 @@ export class TrainSchedulingService {
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
${pendingContainers ? `<div class="tile"><span>To load en route</span><strong>${esc(pendingContainers)} containers</strong></div>` : ''}
<div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
</div>
@@ -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<Record<string, unknown>> {
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<WagonAllocationSnapshot> {
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

View File

@@ -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 (
<Group grow align="flex-start" gap="sm" wrap="wrap">
<DateInput
@@ -45,7 +52,7 @@ export function DoCollectionDateFields({
onChange={(v) =>
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

View File

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

View File

@@ -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<Date | null>(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<HandlingState>(EMPTY_HANDLING);
useEffect(() => {
if (!opened) return;
@@ -121,34 +125,6 @@ export function CheckpointTimeModal({
radius="md"
/>
<Divider
label="Station work (optional)"
labelPosition="left"
styles={{ label: { fontWeight: 600 } }}
/>
<Text size="xs" c="dimmed" mt={-8}>
Loading and unloading times for this stop. Total handling is unloading start to
loading finish; the rest of the stay reports as other activity.
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
{HANDLING_FIELDS.map(([field, label]) => (
<DateTimePicker
key={field}
label={label}
value={handling[field]}
onChange={(v) =>
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"
/>
))}
</SimpleGrid>
<Textarea
label="Note"
placeholder="Optional"

View File

@@ -217,6 +217,20 @@ export function StationWorkControls({
{fmtTime(log!.startedAt!)} {ended ? fmtTime(log!.endedAt!) : "…"} (
{fmtElapsed(log!.startedAt!, log?.endedAt)})
</Text>
{log?.startedByName || log?.endedByName ? (
<Tooltip
label={[
log?.startedByName ? `Started by ${log.startedByName}` : null,
log?.endedByName ? `Ended by ${log.endedByName}` : null,
]
.filter(Boolean)
.join(" · ")}
>
<Badge size="xs" variant="light" color="gray" radius="sm">
{log?.endedByName ?? log?.startedByName}
</Badge>
</Tooltip>
) : null}
<EditTimeButton
label={`${phase} start`}
value={log!.startedAt!}

View File

@@ -5,6 +5,7 @@ import {
ArrowLeft,
CalendarClock,
CheckCircle2,
Clock,
FileText,
Flag,
MapPin,
@@ -13,6 +14,7 @@ import {
Pencil,
Train,
} from "lucide-react";
import { StationWorkControls } from "@/components/trainScheduling/StationWorkControls";
import {
Alert,
Badge,
@@ -653,6 +655,78 @@ export default function TrainScheduleTrackPage() {
</Stack>
</Paper>
{/* ── Loading / unloading windows per station ── */}
<Paper radius="lg" p="lg" withBorder style={CARD_STYLE}>
<SectionHead
icon={<Clock size={17} />}
title="Loading & unloading windows"
hint="Start and end each station's work window — times, duration and who recorded them"
/>
<Stack gap="sm" mt="md">
{track.stations.map((s, i) => {
const isFirst = i === 0;
const isLast = i === track.stations.length - 1;
const workLog = track.stationWorkLogs?.[s.yardId];
return (
<Paper
key={s.yardId}
withBorder
radius="md"
p="sm"
style={{
background:
track.currentSequenceNo === s.sequenceNo
? "var(--mantine-color-green-0)"
: undefined,
}}
>
<Group gap={10} mb={6} wrap="nowrap">
<ThemeIcon size={30} radius="xl" variant="light" color="edr-green">
{isLast ? <Flag size={15} /> : <MapPin size={15} />}
</ThemeIcon>
<Text fw={700} size="sm">
{s.label}
</Text>
{isFirst ? (
<Badge size="xs" variant="light" color="edr-green">
origin
</Badge>
) : null}
{isLast ? (
<Badge size="xs" variant="light" color="gray">
destination
</Badge>
) : null}
{track.currentSequenceNo === s.sequenceNo ? (
<Badge size="xs" variant="filled" color="edr-green">
train here
</Badge>
) : null}
</Group>
<Stack gap={6} pl={40}>
{!isLast ? (
<StationWorkControls
scheduleId={scheduleId ?? ""}
yardId={s.yardId}
phase="loading"
log={workLog?.loading}
/>
) : null}
{!isFirst ? (
<StationWorkControls
scheduleId={scheduleId ?? ""}
yardId={s.yardId}
phase="unloading"
log={workLog?.unloading}
/>
) : null}
</Stack>
</Paper>
);
})}
</Stack>
</Paper>
{/* ── Checkpoint log ── */}
<Paper radius="lg" p="lg" withBorder style={CARD_STYLE}>
<Group justify="space-between" wrap="nowrap" mb="md">

View File

@@ -499,10 +499,11 @@ export default function TrainScheduleV2DetailPage() {
? schedule.stationWorkLogs?.[originYardId]?.loading
: undefined;
const originLoadingStarted = Boolean(originLoadingLog?.startedAt);
const dispatchBoardersKept = pendingOriginBoarders.some(
(b) => b.isGovernment || dispatchLoadedIds.has(b.id),
);
const dispatchNeedsLoadingStart = dispatchBoardersKept && !originLoadingStarted;
const originLoadingEnded = Boolean(originLoadingLog?.endedAt);
// Dispatch requires the origin's loading window to be COMPLETE (started AND
// ended): not started → disabled, in progress → disabled, ended → active.
// Same gate the server enforces.
const dispatchBlockedByLoading = !originLoadingEnded;
const finalizeStep = hasContainerStep ? 3 : 2;
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
@@ -961,10 +962,11 @@ export default function TrainScheduleV2DetailPage() {
phase="loading"
log={originLoadingLog}
/>
{dispatchNeedsLoadingStart ? (
{dispatchBlockedByLoading ? (
<Text size="xs" c="dimmed">
Start loading before dispatching the ticked bookings are marked
loaded at dispatch, which needs an open loading window.
{originLoadingStarted
? "End the loading window before dispatching — a train never departs mid-loading."
: "Start and end the loading window before dispatching — dispatch needs a completed loading window."}
</Text>
) : null}
</Stack>
@@ -1699,14 +1701,18 @@ export default function TrainScheduleV2DetailPage() {
Cancel
</Button>
<Tooltip
label="Start loading at the origin station first — dispatch marks the ticked bookings loaded"
disabled={!dispatchNeedsLoadingStart}
label={
originLoadingStarted
? "End the loading window at the origin station first — a train never departs mid-loading"
: "Start and end the loading window at the origin station first — dispatch needs a completed loading window"
}
disabled={!dispatchBlockedByLoading}
>
<Button
color="edr-green"
leftSection={<Send size={16} />}
loading={dispatch.isPending}
disabled={dispatchNeedsLoadingStart}
disabled={dispatchBlockedByLoading}
onClick={() => void runDispatch()}
>
{hasDispatchWarnings ? "Dispatch anyway" : "Dispatch train"}

View File

@@ -902,6 +902,9 @@ export interface StationWorkPhaseLog {
endedAt?: string | null;
startedByUserId?: string | null;
endedByUserId?: string | null;
/** Resolved display names of the recorders (track endpoint only). */
startedByName?: string | null;
endedByName?: string | null;
}
export interface StationWorkLog {

View File

@@ -328,12 +328,32 @@ export interface WagonAllocationSnapshotAllocation {
containerNumbers: string[];
}
/**
* Coupled-but-empty consist wagon captured with the frozen plan. The physical
* wagons are released and re-pinned to later trains after dispatch/arrival, so
* this snapshot is the only record of which empties rode THIS departure.
*/
export interface WagonAllocationSnapshotEmptyWagon {
physicalWagonId: string;
physicalWagonNumber: string | null;
/** Real coupling position on the built train at capture time. */
sequenceNo: number;
wagonTypeId: string | null;
wagonTypeCode: string | null;
wagonTypeName: string | null;
capacityTons: number;
tareWeightTons: number | null;
lengthMeters: number;
}
/** Whole-schedule frozen wagon plan written at a terminal/transit transition. */
export interface WagonAllocationSnapshot {
/** Schedule status the snapshot was captured at (DISPATCHED/ARRIVED/CANCELLED). */
capturedStatus: string;
capturedAt: string;
slots: WagonAllocationSnapshotSlot[];
/** Absent on snapshots captured before empties were recorded. */
emptyConsistWagons?: WagonAllocationSnapshotEmptyWagon[];
}
export type ScheduleTradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC";