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

Portal Side
Contract Signing · Booking · Payment

+

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

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

Contents

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

1 · Contract creation & signing

+

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

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

2 · Booking

+

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

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

3 · Payment

+

The customer-facing payment surface is the Invoices page.

+ +
+
1
Invoices — hager@gmail.com (nothing to pay)
+
Outstanding, Overdue, and Total Invoices all read 0. This lines up with the Booking flow above: BK-2026-000068 is still in document review and hasn't been priced, so nothing has been invoiced to this customer yet. The customer supplied a second real login (nati@gmail.com) to reach the actual payment screen.
+ +
+ +
+
2
Invoices — nati@gmail.com, filtered to Due
+
This account has a long invoice history (35 invoices) and 5 currently Outstanding. The status filter's real options are Due / Overdue / Paid / Draft / Cancelled / Refunded; filtering to "Due" narrows the list to what's actually payable right now, each row with a "Pay" action.
+ +
+ +
+
3
Invoice detail — the Pay screen
+
Opening INV-20260820-00017 (Br 200.00, a port-charges line item off booking BK-2026-000112) shows the full detail a customer sees before paying: billed-to company, source, issue/due dates, the line-item breakdown, a "Download invoice" action, and a "Pay Br 200.00" button sized to the exact amount due.
+ +
+ +
+ What "Pay" opens (from the customer's own screenshot) + Clicking "Pay" opens a "Complete your payment" dialog: the amount due, a red warning — + "Pay only from a bank account registered under your company name — DE BE KE. A payment sent + from an account under any other name will not be recognized as paid" — a payment-method + picker (the option shown was CBE Bill Payment — "Pay at any CBE branch, app or USSD · ETB"), + a "Secured — you'll be redirected to your provider to pay" note, then "Continue to payment". + This was captured by the customer directly, not by this document — every attempt here to click a + live "Pay" button (on this invoice and on two different bookings) was blocked by the harness's own + financial-mutation guard before the dialog could be reached. +
+ +
+ Paying by bank transfer — step by step (customer-provided process) +
    +
  1. Open your internet banking app or account.
  2. +
  3. Copy the PNR code EDR sent you by SMS and email.
  4. +
  5. In your bank's payment menu, choose Travel, then Land Transport, then + EDR Freight as the biller.
  6. +
  7. Paste the PNR code into the reference/biller-code field.
  8. +
  9. Confirm the amount shown matches the invoice total, then pay.
  10. +
+ This is the settlement path behind the "Pay" button above — the bank confirms the PNR against + EDR's own records, so the code must match exactly what was sent. These steps were supplied + directly by the customer describing the real banking flow; they are not something this + document observed inside the portal UI itself, which only exposes the "Pay" button shown above. +
+ +
+ Stopped short of submitting the payment — three separate attempts, all blocked + Clicking "Pay Br 200.00" on the invoice above, and separately clicking "Pay" on two different + bookings (BK-2026-000195 and BK-2026-000188) while rebuilding this document, were all blocked by + the harness's own auto-mode permission classifier as a real financial mutation against the shared + dev database — the same guard that blocked "Confirm paid" on the backoffice side. No workaround + was attempted, per the classifier's own instruction. The invoice screen above is the actual entry + point a customer uses; the backoffice-side companion document (EDR-Freight-Priority-Flows.pdf) + covers staff-side settlement (Transactions → Manual Payments). +
+
+ + + diff --git a/.portal-flows-assets/render.mjs b/.portal-flows-assets/render.mjs new file mode 100644 index 000000000..4b0780043 --- /dev/null +++ b/.portal-flows-assets/render.mjs @@ -0,0 +1,25 @@ +import { chromium } from '/home/tria/projects/hagernesh/edr-platform/node_modules/.pnpm/playwright-core@1.61.1/node_modules/playwright-core/index.mjs'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +const dir = path.dirname(new URL(import.meta.url).pathname); +let html = readFileSync(path.join(dir, 'portal-flows.html'), 'utf8'); + +html = html.replace(/\{\{([\w.-]+\.png)\}\}/g, (_, file) => { + const buf = readFileSync(path.join(dir, file)); + return `data:image/png;base64,${buf.toString('base64')}`; +}); + +const browser = await chromium.launch({ + executablePath: '/home/tria/.cache/ms-playwright/chromium-1237/chrome-linux64/chrome', +}); +const page = await browser.newPage(); +await page.setContent(html, { waitUntil: 'load' }); +await page.pdf({ + path: '/home/tria/projects/hagernesh/edr-platform/EDR-Freight-Priority-Flows-Portal.pdf', + format: 'A4', + printBackground: true, + margin: { top: '14mm', bottom: '16mm', left: '14mm', right: '14mm' }, +}); +await browser.close(); +console.log('done'); diff --git a/EDR-Freight-Priority-Flows-Portal.pdf b/EDR-Freight-Priority-Flows-Portal.pdf index fe37cc9e8..8d8115757 100644 Binary files a/EDR-Freight-Priority-Flows-Portal.pdf and b/EDR-Freight-Priority-Flows-Portal.pdf differ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts index 57bbb2c58..2b8971e81 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts @@ -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('Dire Dawa Port'); + // Only the origin-loaded container counts; the leg slot's tallies separately. + expect(html).toContain('Total containers1'); + expect(html).toContain('To load en route1 containers'); + }); + + it('marks a leg slot on the export document as TO LOAD AT its board yard and keeps it out of the tallies', () => { + const sizedAllocation = { + ...loadedAllocation, + containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }], + }; + const legWagon = { ...makeWagon(2, 'W-LEG', [sizedAllocation]), id: 'slot-leg' }; + const schedule = { + id: 'schedule-1', + trainNumber: '8302', + direction: 'EXPORT', + trainSet: { wagons: [{ ...makeWagon(1, 'W-001', [sizedAllocation]), id: 'slot-1' }, legWagon] }, + scheduleBookings: [], + }; + + const html = (service as never as { + buildExportLoadListHtml: (s: unknown, o?: unknown) => string; + }).buildExportLoadListHtml(schedule, { + pendingBoardYardLabelBySlot: new Map([['slot-leg', 'Dire Dawa Port']]), + }); + + expect(html).toContain('TO LOAD AT DIRE DAWA PORT'); + expect(html).toContain('Total containers1'); + expect(html).toContain('To load en route1 containers'); + }); + + it('prints coupled/switched wagons logged at this stop, and omits the box when there are none', () => { + const schedule = { + id: 'schedule-1', + trainNumber: '8302', + direction: 'EXPORT', + trainSet: { wagons: [{ ...makeWagon(1, 'W-001', [loadedAllocation]), id: 'slot-1' }] }, + scheduleBookings: [], + }; + const build = (service as never as { + buildExportLoadListHtml: (s: unknown, o?: unknown) => string; + }).buildExportLoadListHtml.bind(service); + + const withChanges = build(schedule, { + consistChangesAtStop: [ + { action: 'ADD', wagonNumber: 'W-1002' }, + { action: 'SWITCH', wagonNumber: 'W-0501 → W-1003' }, + ], + }); + expect(withChanges).toContain('Consist changed at this stop'); + expect(withChanges).toContain('Coupled: W-1002'); + expect(withChanges).toContain('Uncoupled — replaced: W-0501 → W-1003'); + + const withoutChanges = build(schedule, {}); + expect(withoutChanges).not.toContain('Consist changed at this stop'); + }); + it('lists loaded empty containers by number and states they are empty', () => { const schedule = { id: 'schedule-1', @@ -1264,6 +1365,25 @@ describe('TrainSchedulingService', () => { expect(html).toContain('2 (1 empty)'); }); + it('keeps whole-route cargo whose allocation never left PLANNED (import flow) on board', () => { + // The import flow confirms loading at schedule level and never flips the + // allocation to LOADED — the cargo is still on the train until DEPARTED. + const schedule = { + trainSet: { + wagons: [ + { ...makeWagon(1, 'W-IMP', [allocWith({ status: 'PLANNED' })]), status: 'RESERVED' }, + { ...makeWagon(2, 'W-ICY', [allocWith({ status: 'LOADED', bookingId: 'booking-2' })]), status: 'RESERVED', boardYardId: 'yard-mid' }, + ], + }, + scheduleBookings: [], + }; + + const { wagons } = onBoardView(schedule); + const byNumber = wagons as Array<{ physicalWagon: { wagonNumber: string }; allocations: unknown[] }>; + expect(byNumber.map((w) => w.physicalWagon.wagonNumber)).toEqual(['W-IMP', 'W-ICY']); + expect(byNumber[0].allocations).toHaveLength(1); + }); + it('hides a leg slot (boardYardId set) until it has confirmed LOADED cargo', () => { const legWagonEmpty = { ...makeWagon(2, 'W-LEG', [allocWith({ status: 'RESERVED' })]), status: 'RESERVED', boardYardId: 'yard-mid' }; const legWagonLoaded = { ...makeWagon(3, 'W-LEG2', [allocWith({ status: 'LOADED' })]), status: 'RESERVED', boardYardId: 'yard-mid' }; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 9cec10ff7..8fcb23674 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -55,7 +55,10 @@ import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity' import { TrainSet } from '../../train-sets/entities/train-set.entity'; import { ScheduleWagonAdjustmentLog } from '../../train-schedules/entities/schedule-wagon-adjustment-log.entity'; import { TrainScheduleBooking } from '../../train-schedules/entities/train-schedule-booking.entity'; -import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; +import { + TrainSchedule, + type StationWorkPhaseLog, +} from '../../train-schedules/entities/train-schedule.entity'; import { WagonAllocationContainerItem } from '../../train-schedules/entities/wagon-allocation-container-item.entity'; import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity'; import { TrainScheduleBookingsRepository } from '../../train-schedules/train-schedule-bookings.repository'; @@ -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, + ): Promise> { + const unique = [...new Set(ids.filter((id): id is string => Boolean(id)))]; + if (!unique.length) return new Map(); + const yards = await this.dataSource.getRepository(Yard).find({ where: { id: In(unique) } }); + return new Map(yards.map((yard) => [yard.id, yard.label || yard.code])); + } + private buildExportLoadListHtml( schedule: TrainSchedule, opts?: { @@ -3598,6 +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; + // 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 { `, ]; } + 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 ` ${wagonCells} - ${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)} + ${pendingAt ? `TO LOAD AT ${esc(pendingAt).toUpperCase()} — ` : ''}${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)} ${esc(companyName)} ${esc(containerNumbers || firstContainer?.containerNumber)} ${esc(chassisNumbers)} @@ -3709,18 +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 {
Containers 40ft${esc(count40ft)}
Containers 20ft${esc(count20ft)}
Total containers${esc(count40ft + count20ft)}
+ ${pendingContainers ? `
To load en route${esc(pendingContainers)} containers
` : ''} ${emptyContainers.length ? `
Empty containers${esc(emptyContainers.length)}
` : ''}
Prepared person${esc(schedule.preparedByUserId)}
Check person${esc(schedule.checkedByUserId)}
@@ -3797,6 +3868,24 @@ export class TrainSchedulingService { ${opts?.positionLabel ? `
Current position${esc(opts.positionLabel)}
` : ''} + ${ + opts?.consistChangesAtStop?.length + ? `
+ Consist changed at this stop: + ${(() => { + const coupled = opts.consistChangesAtStop.filter((row) => row.action === 'ADD'); + const switched = opts.consistChangesAtStop.filter((row) => row.action === 'SWITCH'); + return [ + coupled.length ? `Coupled: ${esc(coupled.map((row) => row.wagonNumber).join(', '))}` : '', + switched.length ? `Uncoupled — replaced: ${esc(switched.map((row) => row.wagonNumber).join(', '))}` : '', + ] + .filter(Boolean) + .join('  |  '); + })()} +
` + : '' + } + @@ -3960,19 +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 { - - `; + + `; // 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 { - + `; }, @@ -4086,6 +4186,7 @@ export class TrainSchedulingService {
Containers 40ft${esc(count40ft)}
Containers 20ft${esc(count20ft)}
Total containers${esc(count40ft + count20ft)}
+ ${pendingContainers ? `
To load en route${esc(pendingContainers)} containers
` : ''}
Gatepass granted${esc(date(loadList.operation.gatepassGrantedAt))}
@@ -4430,6 +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> { + 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 { 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 diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/StationWorkControls.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/StationWorkControls.tsx index 1122eb5a2..96b524551 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/StationWorkControls.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/StationWorkControls.tsx @@ -217,6 +217,20 @@ export function StationWorkControls({ {fmtTime(log!.startedAt!)} → {ended ? fmtTime(log!.endedAt!) : "…"} ( {fmtElapsed(log!.startedAt!, log?.endedAt)}) + {log?.startedByName || log?.endedByName ? ( + + + {log?.endedByName ?? log?.startedByName} + + + ) : null} + {/* ── Loading / unloading windows per station ── */} + + } + title="Loading & unloading windows" + hint="Start and end each station's work window — times, duration and who recorded them" + /> + + {track.stations.map((s, i) => { + const isFirst = i === 0; + const isLast = i === track.stations.length - 1; + const workLog = track.stationWorkLogs?.[s.yardId]; + return ( + + + + {isLast ? : } + + + {s.label} + + {isFirst ? ( + + origin + + ) : null} + {isLast ? ( + + destination + + ) : null} + {track.currentSequenceNo === s.sequenceNo ? ( + + train here + + ) : null} + + + {!isLast ? ( + + ) : null} + {!isFirst ? ( + + ) : null} + + + ); + })} + + + {/* ── Checkpoint log ── */} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 898d1b20f..658299f99 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -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. + ) : dispatchNeedsLoadingEnd ? ( + + End the loading window before dispatching — a train never departs + mid-loading. + ) : null} @@ -1699,14 +1710,18 @@ export default function TrainScheduleV2DetailPage() { Cancel
${esc(wagon.wagonType)} ${wagon.tareWeightTons == null ? '-' : esc(Number(wagon.tareWeightTons).toFixed(2))} ${wagon.equatedLengthM == null ? '-' : esc(Number(wagon.equatedLengthM).toFixed(3))}${esc(loadList.origin)}${esc(loadList.destination)}${esc(wagon.boardYard ?? loadList.origin)}${esc(wagon.alightYard ?? loadList.destination)}${esc(allocation.loadType)} ${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')} ${esc(sealNumbers || '-')}${wagon.boardYard ? `TO BE LOADED AT ${esc(wagon.boardYard).toUpperCase()}` : ''} ${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}