Merge pull request #1418 from Tria-plc/credit-invoice
fix(train-scheduling): marshalling docs respect mid-corridor wagon co…
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,81 @@ describe('TrainSchedulingService', () => {
|
|||||||
expect(html).toContain('2 (1 empty)');
|
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('lists loaded empty containers by number and states they are empty', () => {
|
it('lists loaded empty containers by number and states they are empty', () => {
|
||||||
const schedule = {
|
const schedule = {
|
||||||
id: 'schedule-1',
|
id: 'schedule-1',
|
||||||
@@ -1264,6 +1339,25 @@ describe('TrainSchedulingService', () => {
|
|||||||
expect(html).toContain('2 (1 empty)');
|
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', () => {
|
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 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' };
|
const legWagonLoaded = { ...makeWagon(3, 'W-LEG2', [allocWith({ status: 'LOADED' })]), status: 'RESERVED', boardYardId: 'yard-mid' };
|
||||||
|
|||||||
@@ -3398,6 +3398,11 @@ export class TrainSchedulingService {
|
|||||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||||
const generatedAt = operation.loadListGeneratedAt ?? new Date();
|
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, {
|
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||||||
loadListGeneratedAt: generatedAt,
|
loadListGeneratedAt: generatedAt,
|
||||||
@@ -3424,6 +3429,8 @@ export class TrainSchedulingService {
|
|||||||
wagonType: wagon.wagonType?.code ?? wagon.wagonType?.name ?? null,
|
wagonType: wagon.wagonType?.code ?? wagon.wagonType?.name ?? null,
|
||||||
tareWeightTons: wagon.wagonType?.tareWeightTons ?? null,
|
tareWeightTons: wagon.wagonType?.tareWeightTons ?? null,
|
||||||
equatedLengthM: wagon.wagonType?.equatedLengthM ?? 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) => ({
|
allocations: (wagon.allocations ?? []).map((allocation) => ({
|
||||||
bookingId: allocation.bookingId,
|
bookingId: allocation.bookingId,
|
||||||
bookingReference: allocation.booking?.reference ?? null,
|
bookingReference: allocation.booking?.reference ?? null,
|
||||||
@@ -3464,7 +3471,19 @@ export class TrainSchedulingService {
|
|||||||
throw new BadRequestException('Export marshalling document applies only to EXPORT schedules');
|
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, {
|
const html = this.buildExportLoadListHtml(schedule, {
|
||||||
|
pendingBoardYardLabelBySlot,
|
||||||
emptyContainers: await this.loadedEmptyContainers(scheduleId),
|
emptyContainers: await this.loadedEmptyContainers(scheduleId),
|
||||||
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
||||||
});
|
});
|
||||||
@@ -3482,8 +3501,11 @@ export class TrainSchedulingService {
|
|||||||
* intercity marshalling ("Marshalling 2") document printed after mid-corridor
|
* intercity marshalling ("Marshalling 2") document printed after mid-corridor
|
||||||
* station work. A wagon slot is on the train iff it has not DEPARTED and
|
* 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
|
* either rides the whole corridor (no boardYardId) or has confirmed LOADED
|
||||||
* cargo. Kept wagons carry only their LOADED allocations (DEPARTED =
|
* cargo. Whole-route cargo counts as on board unless DEPARTED (unloaded) —
|
||||||
* unloaded, PLANNED/RESERVED = not on board yet).
|
* 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
|
* ponytail: boardYardId presence is the "boarded yet?" heuristic; upgrade
|
||||||
* path is comparing the board yard against the latest checkpoint sequence.
|
* path is comparing the board yard against the latest checkpoint sequence.
|
||||||
*/
|
*/
|
||||||
@@ -3499,7 +3521,9 @@ export class TrainSchedulingService {
|
|||||||
})
|
})
|
||||||
.map((wagon) => ({
|
.map((wagon) => ({
|
||||||
...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[];
|
})) as TrainSetWagon[];
|
||||||
|
|
||||||
const onBoardBookingIds = new Set(
|
const onBoardBookingIds = new Set(
|
||||||
@@ -3589,6 +3613,15 @@ export class TrainSchedulingService {
|
|||||||
.find({ where: { trainScheduleId: scheduleId } });
|
.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(
|
private buildExportLoadListHtml(
|
||||||
schedule: TrainSchedule,
|
schedule: TrainSchedule,
|
||||||
opts?: {
|
opts?: {
|
||||||
@@ -3598,6 +3631,9 @@ export class TrainSchedulingService {
|
|||||||
unassignedBookings?: Booking[];
|
unassignedBookings?: Booking[];
|
||||||
emptyContainers?: EmptyContainerReturn[];
|
emptyContainers?: EmptyContainerReturn[];
|
||||||
logoImageUrl?: string | null;
|
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>;
|
||||||
},
|
},
|
||||||
): string {
|
): string {
|
||||||
const esc = (value: unknown) =>
|
const esc = (value: unknown) =>
|
||||||
@@ -3661,6 +3697,7 @@ export class TrainSchedulingService {
|
|||||||
</tr>`,
|
</tr>`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
const pendingAt = opts?.pendingBoardYardLabelBySlot?.get(wagon.id);
|
||||||
return allocations.map((allocation) => {
|
return allocations.map((allocation) => {
|
||||||
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
|
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
|
||||||
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
|
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
|
||||||
@@ -3672,7 +3709,7 @@ export class TrainSchedulingService {
|
|||||||
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
|
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
|
||||||
return `<tr>
|
return `<tr>
|
||||||
${wagonCells}
|
${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(companyName)}</td>
|
||||||
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
|
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
|
||||||
<td>${esc(chassisNumbers)}</td>
|
<td>${esc(chassisNumbers)}</td>
|
||||||
@@ -3709,18 +3746,27 @@ export class TrainSchedulingService {
|
|||||||
(wagon.allocations ?? []).length === 0 &&
|
(wagon.allocations ?? []).length === 0 &&
|
||||||
!emptiesByWagon.get(Number(wagon.sequenceNo))?.length,
|
!emptiesByWagon.get(Number(wagon.sequenceNo))?.length,
|
||||||
).length;
|
).length;
|
||||||
|
const loadsHere = (wagon: TrainSetWagon) => !opts?.pendingBoardYardLabelBySlot?.get(wagon.id);
|
||||||
const totalWeight = wagons.reduce(
|
const totalWeight = wagons.reduce(
|
||||||
(sum, wagon) =>
|
(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,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Container count summary (40ft, 20ft) — empties returning to Djibouti are
|
// Container count summary (40ft, 20ft) — empties returning to Djibouti are
|
||||||
// physically on the train, so they count, and are called out on their own tile.
|
// physically on the train, so they count, and are called out on their own tile.
|
||||||
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) => {
|
wagons.forEach((wagon) => {
|
||||||
(wagon.allocations ?? []).forEach((allocation) => {
|
(wagon.allocations ?? []).forEach((allocation) => {
|
||||||
(allocation.containerItems ?? []).forEach((item) => {
|
(allocation.containerItems ?? []).forEach((item) => {
|
||||||
|
if (!loadsHere(wagon)) {
|
||||||
|
pendingContainers++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const size = this.resolveContainerItemSize(item);
|
const size = this.resolveContainerItemSize(item);
|
||||||
if (size === 40) count40ft++;
|
if (size === 40) count40ft++;
|
||||||
else if (size === 20) count20ft++;
|
else if (size === 20) count20ft++;
|
||||||
@@ -3787,6 +3833,7 @@ export class TrainSchedulingService {
|
|||||||
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
|
<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>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
|
||||||
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + 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>` : ''}
|
${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>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
|
||||||
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
|
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
|
||||||
@@ -3960,19 +4007,30 @@ export class TrainSchedulingService {
|
|||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleString('en-GB') : '-');
|
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleString('en-GB') : '-');
|
||||||
const status = loadList.operation.status;
|
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 totalAllocations = loadList.wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0);
|
||||||
const totalWeight = loadList.wagons.reduce(
|
const totalWeight = loadList.wagons.reduce(
|
||||||
(sum, wagon) =>
|
(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,
|
0,
|
||||||
);
|
);
|
||||||
const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length;
|
const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length;
|
||||||
|
|
||||||
// Container count summary (40ft, 20ft)
|
// Container count summary (40ft, 20ft) — loaded at origin vs. en route
|
||||||
let count40ft = 0, count20ft = 0;
|
let count40ft = 0, count20ft = 0, pendingContainers = 0;
|
||||||
loadList.wagons.forEach((wagon) => {
|
loadList.wagons.forEach((wagon) => {
|
||||||
wagon.allocations.forEach((allocation) => {
|
wagon.allocations.forEach((allocation) => {
|
||||||
(allocation.containerItems ?? []).forEach((item) => {
|
(allocation.containerItems ?? []).forEach((item) => {
|
||||||
|
if (!loadsHere(wagon)) {
|
||||||
|
pendingContainers++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const size = this.resolveContainerItemSize(item);
|
const size = this.resolveContainerItemSize(item);
|
||||||
if (size === 40) count40ft++;
|
if (size === 40) count40ft++;
|
||||||
else if (size === 20) count20ft++;
|
else if (size === 20) count20ft++;
|
||||||
@@ -3987,8 +4045,8 @@ export class TrainSchedulingService {
|
|||||||
<td>${esc(wagon.wagonType)}</td>
|
<td>${esc(wagon.wagonType)}</td>
|
||||||
<td class="num">${wagon.tareWeightTons == null ? '-' : esc(Number(wagon.tareWeightTons).toFixed(2))}</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 class="num">${wagon.equatedLengthM == null ? '-' : esc(Number(wagon.equatedLengthM).toFixed(3))}</td>
|
||||||
<td>${esc(loadList.origin)}</td>
|
<td>${esc(wagon.boardYard ?? loadList.origin)}</td>
|
||||||
<td>${esc(loadList.destination)}</td>`;
|
<td>${esc(wagon.alightYard ?? loadList.destination)}</td>`;
|
||||||
// An empty wagon still runs in the consist, so it still gets a line — see
|
// An empty wagon still runs in the consist, so it still gets a line — see
|
||||||
// buildExportLoadListHtml.
|
// buildExportLoadListHtml.
|
||||||
if (wagon.allocations.length === 0) {
|
if (wagon.allocations.length === 0) {
|
||||||
@@ -4013,7 +4071,7 @@ export class TrainSchedulingService {
|
|||||||
<td>${esc(allocation.loadType)}</td>
|
<td>${esc(allocation.loadType)}</td>
|
||||||
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
|
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
|
||||||
<td>${esc(sealNumbers || '-')}</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>
|
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
},
|
},
|
||||||
@@ -4086,6 +4144,7 @@ export class TrainSchedulingService {
|
|||||||
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
|
<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>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
|
||||||
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + 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 class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||