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');
|
||||
@@ -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';
|
||||
@@ -2910,14 +2913,20 @@ export class TrainSchedulingService {
|
||||
const boardersToLoad = dto.loadedBookingIds
|
||||
? originBoarders.filter((id) => new Set(dto.loadedBookingIds).has(id))
|
||||
: originBoarders;
|
||||
if (
|
||||
boardersToLoad.length &&
|
||||
!schedule.stationWorkLogs?.[schedule.originStationId]?.loading?.startedAt
|
||||
) {
|
||||
const originLoadingLog =
|
||||
schedule.stationWorkLogs?.[schedule.originStationId]?.loading;
|
||||
if (boardersToLoad.length && !originLoadingLog?.startedAt) {
|
||||
throw new BadRequestException(
|
||||
'Start loading at the origin station before dispatching with cargo to load',
|
||||
);
|
||||
}
|
||||
// A train never departs mid-loading: once the origin's loading window was
|
||||
// opened (or there is cargo to load), it must be ENDED before dispatch.
|
||||
if ((boardersToLoad.length || originLoadingLog?.startedAt) && !originLoadingLog?.endedAt) {
|
||||
throw new BadRequestException(
|
||||
'End the loading window at the origin station before dispatching',
|
||||
);
|
||||
}
|
||||
// Staff may record the departure after the fact — past is fine, future is not.
|
||||
const now = dto.actualDepartureAt ? new Date(dto.actualDepartureAt) : new Date();
|
||||
this.assertNotFuture(now, 'Departure time');
|
||||
@@ -2963,7 +2972,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 +3407,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 +3438,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 +3480,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 +3510,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 +3530,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 +3567,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 +3584,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 +3633,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 +3651,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 +3721,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 +3733,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 +3770,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 +3857,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 +3868,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 +4049,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 +4087,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 +4113,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 +4186,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 +4531,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 +4614,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 +5091,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 +5112,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 +5140,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 +5491,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 +6278,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 +6306,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 +9810,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 +9872,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
|
||||
|
||||
@@ -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,16 @@ export default function TrainScheduleV2DetailPage() {
|
||||
? schedule.stationWorkLogs?.[originYardId]?.loading
|
||||
: undefined;
|
||||
const originLoadingStarted = Boolean(originLoadingLog?.startedAt);
|
||||
const originLoadingEnded = Boolean(originLoadingLog?.endedAt);
|
||||
const dispatchBoardersKept = pendingOriginBoarders.some(
|
||||
(b) => b.isGovernment || dispatchLoadedIds.has(b.id),
|
||||
);
|
||||
const dispatchNeedsLoadingStart = dispatchBoardersKept && !originLoadingStarted;
|
||||
// A train never departs mid-loading: once the window opened (or cargo is to
|
||||
// board), it must be ENDED before dispatch — same gate the server enforces.
|
||||
const dispatchNeedsLoadingEnd =
|
||||
(dispatchBoardersKept || originLoadingStarted) && !originLoadingEnded;
|
||||
const dispatchBlockedByLoading = dispatchNeedsLoadingStart || dispatchNeedsLoadingEnd;
|
||||
|
||||
const finalizeStep = hasContainerStep ? 3 : 2;
|
||||
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
||||
@@ -966,6 +972,11 @@ export default function TrainScheduleV2DetailPage() {
|
||||
Start loading before dispatching — the ticked bookings are marked
|
||||
loaded at dispatch, which needs an open loading window.
|
||||
</Text>
|
||||
) : dispatchNeedsLoadingEnd ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
End the loading window before dispatching — a train never departs
|
||||
mid-loading.
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
@@ -1699,14 +1710,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={
|
||||
dispatchNeedsLoadingStart
|
||||
? "Start loading at the origin station first — dispatch marks the ticked bookings loaded"
|
||||
: "End the loading window at the origin station first — a train never departs mid-loading"
|
||||
}
|
||||
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";
|
||||
|
||||