BIN
.portal-flows-assets/00-invoice-detail-pay.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
.portal-flows-assets/01-invoices-due-full.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
.portal-flows-assets/02-login-full.png
Normal file
|
After Width: | Height: | Size: 673 KiB |
BIN
.portal-flows-assets/03-hager-invoices-empty-full.png
Normal file
|
After Width: | Height: | Size: 223 KiB |
BIN
.portal-flows-assets/04-contracts-list-full.png
Normal file
|
After Width: | Height: | Size: 272 KiB |
BIN
.portal-flows-assets/05-home-full.png
Normal file
|
After Width: | Height: | Size: 270 KiB |
BIN
.portal-flows-assets/06-contract-detail-signed-full.png
Normal file
|
After Width: | Height: | Size: 362 KiB |
BIN
.portal-flows-assets/07-contract-step1-full.png
Normal file
|
After Width: | Height: | Size: 200 KiB |
BIN
.portal-flows-assets/08-contract-step1-filled-full.png
Normal file
|
After Width: | Height: | Size: 234 KiB |
BIN
.portal-flows-assets/09-contract-step2-full.png
Normal file
|
After Width: | Height: | Size: 237 KiB |
BIN
.portal-flows-assets/10-contract-step3-review-full.png
Normal file
|
After Width: | Height: | Size: 261 KiB |
BIN
.portal-flows-assets/11-contract-duplicate-blocked-full.png
Normal file
|
After Width: | Height: | Size: 230 KiB |
BIN
.portal-flows-assets/11b-quotation-approve-full.png
Normal file
|
After Width: | Height: | Size: 386 KiB |
BIN
.portal-flows-assets/13-bookings-list-full.png
Normal file
|
After Width: | Height: | Size: 198 KiB |
BIN
.portal-flows-assets/14-booking-documents-modal-full.png
Normal file
|
After Width: | Height: | Size: 289 KiB |
BIN
.portal-flows-assets/15-nati-contracts-list-full.png
Normal file
|
After Width: | Height: | Size: 378 KiB |
BIN
.portal-flows-assets/16-nati-contracts-list-actions.png
Normal file
|
After Width: | Height: | Size: 326 KiB |
BIN
.portal-flows-assets/17-initiate-booking-confirm.png
Normal file
|
After Width: | Height: | Size: 291 KiB |
BIN
.portal-flows-assets/18-nati-bookings-list-actions.png
Normal file
|
After Width: | Height: | Size: 343 KiB |
BIN
.portal-flows-assets/19-complete-booking-cargo-full.png
Normal file
|
After Width: | Height: | Size: 222 KiB |
312
.portal-flows-assets/portal-flows.html
Normal 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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>
|
||||
25
.portal-flows-assets/render.mjs
Normal 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');
|
||||
@@ -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
|
||||
|
||||
@@ -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) },
|
||||
];
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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(' | ');
|
||||
})()}
|
||||
</div>`
|
||||
: ''
|
||||
}
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -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 {
|
||||
<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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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!}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||