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/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 603811178..ccff74387 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -95,6 +95,24 @@ export const TrainSchedulingLoad = () => export const TrainSchedulingUnload = () => BookingStaff(FREIGHT_PERMS.trainScheduling.unload); +/** + * Per-station loading/unloading time windows — the four buttons are four + * permissions so start and end can be granted to different people. The same + * endpoint that records a click also edits it (explicit `at`), so each + * permission covers editing its own timestamp too. + */ +export const TrainSchedulingLoadingStart = () => + BookingStaff(FREIGHT_PERMS.trainScheduling.loadingStart); + +export const TrainSchedulingLoadingEnd = () => + BookingStaff(FREIGHT_PERMS.trainScheduling.loadingEnd); + +export const TrainSchedulingUnloadingStart = () => + BookingStaff(FREIGHT_PERMS.trainScheduling.unloadingStart); + +export const TrainSchedulingUnloadingEnd = () => + BookingStaff(FREIGHT_PERMS.trainScheduling.unloadingEnd); + export const TrainSchedulingCancel = () => BookingStaff(FREIGHT_PERMS.trainScheduling.cancel); diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index 36e4e34d0..6a64f6199 100644 --- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -122,6 +122,8 @@ export class ContractDocumentViewModelBuilder { contract.customsClearingEnabled, // Bulk templates are keyed by the contract's cargo type. (contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId, + // Ethiopian-customs-only service types resolve to the Ethiopian variant. + contract.serviceType?.includesEthiopianCustomsOnly, ); dynamicTemplate = dynamicSource ? { diff --git a/apps/edr-freight-api/src/migrations/3710000000000-BookingClearingAgentContact.ts b/apps/edr-freight-api/src/migrations/3710000000000-BookingClearingAgentContact.ts new file mode 100644 index 000000000..0590d6395 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3710000000000-BookingClearingAgentContact.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds the customs clearing agent's contact details to freight.bookings. + * + * The agent moved from the contract to the booking: on a without-customs + * service the customer now names their agent (name, email, phone) when + * completing each booking, instead of once at contract creation. The existing + * `customs_clearing_agent` column keeps the name; these two columns add the + * contact info. Nullable — customs-bundled and legacy bookings have none. + */ +export class BookingClearingAgentContact3710000000000 implements MigrationInterface { + name = 'BookingClearingAgentContact3710000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS customs_clearing_agent_email varchar(200), + ADD COLUMN IF NOT EXISTS customs_clearing_agent_phone varchar(50) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS customs_clearing_agent_email, + DROP COLUMN IF EXISTS customs_clearing_agent_phone + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3720000000000-ScheduleStationWorkLogs.ts b/apps/edr-freight-api/src/migrations/3720000000000-ScheduleStationWorkLogs.ts new file mode 100644 index 000000000..6998895c2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3720000000000-ScheduleStationWorkLogs.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-station loading/unloading time windows on a schedule, operator-clicked: + * { [yardId]: { loading?: { startedAt, endedAt, startedByUserId, endedByUserId }, + * unloading?: { same } } } + * Booking load/unload is gated on the matching window having been started. + */ +export class ScheduleStationWorkLogs3720000000000 implements MigrationInterface { + name = 'ScheduleStationWorkLogs3720000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS station_work_logs jsonb + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS station_work_logs + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3730000000000-WagonDetachRequests.ts b/apps/edr-freight-api/src/migrations/3730000000000-WagonDetachRequests.ts new file mode 100644 index 000000000..1c7fcb3db --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3730000000000-WagonDetachRequests.ts @@ -0,0 +1,74 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Approval gate for detaching a wagon (or sending it to maintenance) from a + * train whose run is already SCHEDULED. + * + * Before scheduling, the consist is the builder's to edit. After scheduling, + * pulling a wagon changes a departure customers booked against, so it becomes + * a two-person action: one staffer files a request with a reason, another + * staffer (with trains:approve_wagon_detach) approves it — approval executes + * the detach on the spot. Rows are never deleted; decided rows are the audit + * trail of who asked, who decided, and why. + * + * One PENDING row per (train, wagon) at a time — a second request while one is + * undecided is a coordination failure, not a workflow (partial unique index). + */ +export class WagonDetachRequests3730000000000 implements MigrationInterface { + name = 'WagonDetachRequests3730000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ BEGIN + CREATE TYPE freight.wagon_detach_requests_status_enum + AS ENUM ('PENDING', 'APPROVED', 'REJECTED'); + EXCEPTION WHEN duplicate_object THEN NULL; END $$ + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.wagon_detach_requests ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + train_id uuid NOT NULL REFERENCES freight.trains (id), + wagon_id uuid NOT NULL REFERENCES freight.wagons (id), + -- Snapshot: the audit trail must still read correctly after the wagon + -- is renumbered or deleted. + wagon_number varchar(50) NOT NULL, + action varchar(20) NOT NULL, + reason varchar(500) NOT NULL, + status freight.wagon_detach_requests_status_enum NOT NULL DEFAULT 'PENDING', + -- Who asked and who decided. Both recorded: the point of the gate is + -- that they are different people. + requested_by uuid, + decided_by uuid, + decided_at timestamptz, + decision_note varchar(500), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagon_detach_requests_train + ON freight.wagon_detach_requests (train_id) + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagon_detach_requests_train_status + ON freight.wagon_detach_requests (train_id, status) + `); + + // The workflow invariant, enforced where it cannot race: at most one + // undecided request per wagon per train. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_wagon_detach_requests_one_pending + ON freight.wagon_detach_requests (train_id, wagon_id) + WHERE status = 'PENDING' AND deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_detach_requests`); + await queryRunner.query(`DROP TYPE IF EXISTS freight.wagon_detach_requests_status_enum`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3740000000000-BookingBulkRequestedWagons.ts b/apps/edr-freight-api/src/migrations/3740000000000-BookingBulkRequestedWagons.ts new file mode 100644 index 000000000..64c9ac2c7 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3740000000000-BookingBulkRequestedWagons.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * NUMBER_OF_WAGONS cargo unit: the customer books a wagon COUNT alongside the + * bulk weight. `bulk_requested_wagons` drives allocation and PER_WAGON pricing; + * `bulk_item_count` is the optional informational item count entered with it. + * Nullable — every other cargo unit leaves both empty. + */ +export class BookingBulkRequestedWagons3740000000000 implements MigrationInterface { + name = 'BookingBulkRequestedWagons3740000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS bulk_requested_wagons int, + ADD COLUMN IF NOT EXISTS bulk_item_count int + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS bulk_requested_wagons, + DROP COLUMN IF EXISTS bulk_item_count + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3750000000000-EthiopianCustomsContractTemplates.ts b/apps/edr-freight-api/src/migrations/3750000000000-EthiopianCustomsContractTemplates.ts new file mode 100644 index 000000000..564972333 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3750000000000-EthiopianCustomsContractTemplates.ts @@ -0,0 +1,118 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults'; + +/** + * Third customs-clearing option on contract templates: Ethiopian-customs-only + * (the Service Provider clears the Ethiopian side only, Djibouti stays with + * the Client), matching service types with includes_ethiopian_customs_only. + * + * - ethiopian_customs_only column on contract_templates (bulk variant flag; + * the seeded container variants carry it in the code suffix instead, like + * the existing _CUSTOMS/_NO_CUSTOMS pair). + * - The bulk unique index and intercity check widen to the new flag. + * - Seeds the two new system container templates from the defaults pack. + */ +const SEEDED_CODES = [ + 'IMPORT_CONTAINER_ETHIOPIAN_CUSTOMS', + 'EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS', +] as const; + +export class EthiopianCustomsContractTemplates3750000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.contract_templates + ADD COLUMN IF NOT EXISTS ethiopian_customs_only boolean + `); + + await queryRunner.query( + `DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_dir_customs`, + ); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_dir_customs + ON freight.contract_templates + (cargo_type_id, trade_direction, + COALESCE(with_customs, false), COALESCE(ethiopian_customs_only, false)) + WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL + `); + + await queryRunner.query(` + ALTER TABLE freight.contract_templates + DROP CONSTRAINT IF EXISTS ck_bulk_intercity_no_customs + `); + await queryRunner.query(` + ALTER TABLE freight.contract_templates + ADD CONSTRAINT ck_bulk_intercity_no_customs CHECK ( + cargo_type_id IS NULL + OR ( + trade_direction IN ('IMPORT', 'EXPORT', 'INTERCITY') + AND (trade_direction = 'INTERCITY') = (with_customs IS NULL) + AND (ethiopian_customs_only IS NOT TRUE OR with_customs IS TRUE) + ) + ) + `); + + for (const code of SEEDED_CODES) { + const seed = CONTRACT_TEMPLATE_DEFAULTS.find((t) => t.code === code); + if (!seed) throw new Error(`Missing contract template default for ${code}`); + await queryRunner.query( + `INSERT INTO freight.contract_templates + (id, code, name, description, document_title, whereas_clauses, articles, + is_active, is_system, created_at, updated_at) + SELECT gen_random_uuid(), $1::varchar, $2, $3, $4, $5::jsonb, $6::jsonb, + true, true, now(), now() + WHERE NOT EXISTS ( + SELECT 1 FROM freight.contract_templates + WHERE code = $1::varchar AND deleted_at IS NULL + )`, + [ + seed.code, + seed.name, + seed.description, + seed.documentTitle, + JSON.stringify(seed.whereasClauses), + JSON.stringify( + seed.articles.map((article, index) => ({ ...article, order: index + 1 })), + ), + ], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM freight.contract_templates WHERE code = ANY($1) AND is_system = true`, + [[...SEEDED_CODES]], + ); + + await queryRunner.query(` + ALTER TABLE freight.contract_templates + DROP CONSTRAINT IF EXISTS ck_bulk_intercity_no_customs + `); + await queryRunner.query(` + ALTER TABLE freight.contract_templates + ADD CONSTRAINT ck_bulk_intercity_no_customs CHECK ( + cargo_type_id IS NULL + OR ( + trade_direction IN ('IMPORT', 'EXPORT', 'INTERCITY') + AND (trade_direction = 'INTERCITY') = (with_customs IS NULL) + ) + ) + `); + + await queryRunner.query( + `DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_dir_customs`, + ); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_dir_customs + ON freight.contract_templates + (cargo_type_id, trade_direction, COALESCE(with_customs, false)) + WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL + `); + + await queryRunner.query(` + ALTER TABLE freight.contract_templates + DROP COLUMN IF EXISTS ethiopian_customs_only + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index 17d1ba2dc..808421a0c 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -591,6 +591,24 @@ export class BookingWagonCancellationService { this.logger.error( `Consolidation-lapse cancellation failed for paid booking ${payload.paidBookingId}: ${err instanceof Error ? err.message : String(err)}`, ); + // A silent failure here leaves a PAID half-wagon booking boarding alone + // (BK-2026-000201: no LIVE IMPORT 20ft CANCELLATION_FEE rate — the fee + // pricing threw and the booking stayed PAID). Scream to staff so it is + // fixed and the booking cancelled by hand instead of shipping. + try { + const failed = await this.bookingsRepository.findById( + payload.paidBookingId, + ); + if (failed) { + this.notifyStaff( + failed, + 'Consolidation-lapse cancellation FAILED — action needed', + `${failed.reference}: its consolidation partner lapsed unpaid, but the automatic cancellation failed: ${err instanceof Error ? err.message : String(err)}. Fix the cause (usually a missing LIVE per-wagon CANCELLATION_FEE rate for this trade direction + container size), then cancel the whole booking manually so the fee is invoiced and its wagons are freed.`, + ); + } + } catch { + // Notification is best-effort — the error log above already fired. + } } } diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index c85e15cfb..67a486068 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -359,6 +359,12 @@ export class Booking extends BaseEntity { @Column({ name: 'customs_clearing_agent', type: 'varchar', length: 200, nullable: true }) customsClearingAgent?: string | null; + @Column({ name: 'customs_clearing_agent_email', type: 'varchar', length: 200, nullable: true }) + customsClearingAgentEmail?: string | null; + + @Column({ name: 'customs_clearing_agent_phone', type: 'varchar', length: 50, nullable: true }) + customsClearingAgentPhone?: string | null; + @Column({ name: 'equipment_return', type: 'varchar', length: 20 }) equipmentReturn!: string; @@ -411,6 +417,23 @@ export class Booking extends BaseEntity { @Column({ name: 'bulk_total_weight_tons', type: 'numeric', precision: 12, scale: 3, nullable: true }) bulkTotalWeightTons?: number | null; + /** + * NUMBER_OF_WAGONS bulk only: the wagon count the customer asked for at + * booking. Allocation and PER_WAGON pricing use this count verbatim, and the + * cargo weight spreads evenly across it (weight ÷ count per wagon — validated + * against wagon capacity at creation). Null for every other cargo unit. + */ + @Column({ name: 'bulk_requested_wagons', type: 'int', nullable: true }) + bulkRequestedWagons?: number | null; + + /** + * NUMBER_OF_WAGONS bulk only: optional informational item count entered with + * the weight. Never prices or sizes anything (unlike PER_ITEM, where the + * count lives in cargoTotalWeightVgm). + */ + @Column({ name: 'bulk_item_count', type: 'int', nullable: true }) + bulkItemCount?: number | null; + @Column({ name: 'is_hazardous', type: 'boolean', default: false }) isHazardous!: boolean; diff --git a/apps/edr-freight-api/src/modules/contract-templates/bulk-template-direction.spec.ts b/apps/edr-freight-api/src/modules/contract-templates/bulk-template-direction.spec.ts index b822387da..1c1b304c9 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/bulk-template-direction.spec.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/bulk-template-direction.spec.ts @@ -34,15 +34,27 @@ describe('bulkTemplateCode', () => { expect(bulkTemplateCode('STEEL', 'INTERCITY', null)).toBe('BULK_INTERCITY_STEEL'); }); - it('produces 5 distinct codes per cargo type', () => { + it('gives the Ethiopian-customs-only variant its own suffix', () => { + expect(bulkTemplateCode('STEEL', 'IMPORT', true, true)).toBe( + 'BULK_IMPORT_STEEL_ETHIOPIAN_CUSTOMS', + ); + // The flag is meaningless without customs clearing. + expect(bulkTemplateCode('STEEL', 'IMPORT', false, true)).toBe( + 'BULK_IMPORT_STEEL_NO_CUSTOMS', + ); + }); + + it('produces 7 distinct codes per cargo type', () => { const codes = [ bulkTemplateCode('STEEL', 'IMPORT', true), + bulkTemplateCode('STEEL', 'IMPORT', true, true), bulkTemplateCode('STEEL', 'IMPORT', false), bulkTemplateCode('STEEL', 'EXPORT', true), + bulkTemplateCode('STEEL', 'EXPORT', true, true), bulkTemplateCode('STEEL', 'EXPORT', false), bulkTemplateCode('STEEL', 'INTERCITY', null), ]; - expect(new Set(codes).size).toBe(5); + expect(new Set(codes).size).toBe(7); }); }); @@ -112,6 +124,33 @@ describe('ContractTemplatesService bulk create/resolve', () => { ).rejects.toBeInstanceOf(BadRequestException); }); + it('creates the Ethiopian-customs-only variant alongside the full-customs one', async () => { + const { service } = build(); + const created = await service.create({ + cargoTypeId: 'cargo-1', + tradeDirection: 'IMPORT', + withCustoms: true, + ethiopianCustomsOnly: true, + }); + expect(created.code).toBe('BULK_IMPORT_STEEL_ETHIOPIAN_CUSTOMS'); + expect(created.ethiopianCustomsOnly).toBe(true); + expect(created.documentTitle).toBe( + 'Steel Transportation and Ethiopian Customs Clearance Services', + ); + }); + + it('rejects Ethiopian-customs-only without customs clearing', async () => { + const { service } = build(); + await expect( + service.create({ + cargoTypeId: 'cargo-1', + tradeDirection: 'IMPORT', + withCustoms: false, + ethiopianCustomsOnly: true, + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + it('resolves a domestic bulk contract to the intercity template, ignoring its customs flag', async () => { const { repository, service } = build(); await service.findActiveForContract('DOMESTIC', 'BULK', true, 'cargo-1'); @@ -119,6 +158,7 @@ describe('ContractTemplatesService bulk create/resolve', () => { 'cargo-1', 'INTERCITY', null, + false, ); }); @@ -129,6 +169,18 @@ describe('ContractTemplatesService bulk create/resolve', () => { 'cargo-1', 'IMPORT', false, + false, + ); + }); + + it('resolves an Ethiopian-customs-only contract to the Ethiopian variant', async () => { + const { repository, service } = build(); + await service.findActiveForContract('IMPORT', 'BULK', true, 'cargo-1', true); + expect(repository.findActiveBulkTemplate).toHaveBeenCalledWith( + 'cargo-1', + 'IMPORT', + true, + true, ); }); }); diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts index 803814967..50a912a1d 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts @@ -16,6 +16,22 @@ describe('contractTemplateCodeFor', () => { ); }); + it('resolves the Ethiopian variant only when customs clearing is enabled', () => { + expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', true, true)).toBe( + 'IMPORT_CONTAINER_ETHIOPIAN_CUSTOMS', + ); + expect(contractTemplateCodeFor('EXPORT', 'BULK', true, true)).toBe( + 'EXPORT_BULK_ETHIOPIAN_CUSTOMS', + ); + // Without customs clearing the Ethiopian flag is meaningless. + expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', false, true)).toBe( + 'IMPORT_CONTAINER_NO_CUSTOMS', + ); + expect(contractTemplateCodeFor('DOMESTIC', 'CONTAINER', true, true)).toBe( + 'INTERCITY_CONTAINER', + ); + }); + it('never gives intercity a customs variant — it crosses no border', () => { for (const flag of [true, false, null, undefined]) { expect(contractTemplateCodeFor('DOMESTIC', 'BULK', flag)).toBe('INTERCITY_BULK'); @@ -40,7 +56,11 @@ describe('contractTemplateCodeFor', () => { for (const d of directions) { for (const f of freights) { for (const c of [true, false]) { - expect(CONTRACT_TEMPLATE_CODES).toContain(contractTemplateCodeFor(d, f, c)); + for (const e of [true, false, undefined]) { + expect(CONTRACT_TEMPLATE_CODES).toContain( + contractTemplateCodeFor(d, f, c, e), + ); + } } } } @@ -48,9 +68,9 @@ describe('contractTemplateCodeFor', () => { }); describe('CONTRACT_TEMPLATE_DEFAULTS', () => { - it('seeds exactly the ten declared codes, once each', () => { + it('seeds exactly the fourteen declared codes, once each', () => { const seeded = CONTRACT_TEMPLATE_DEFAULTS.map((t) => t.code).sort(); - expect(seeded).toHaveLength(10); + expect(seeded).toHaveLength(14); expect(seeded).toEqual([...CONTRACT_TEMPLATE_CODES].sort()); }); diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts index 1df3b1302..58c452f0c 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts @@ -1,7 +1,7 @@ import { BaseRepository } from "@edr/api-common"; import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { IsNull, Repository } from "typeorm"; +import { Repository } from "typeorm"; import { CargoType } from "../rule-engine/entities/cargo-type.entity"; import { @@ -33,10 +33,22 @@ export class ContractTemplatesRepository extends BaseRepository { - return this.repository.findOne({ - where: { cargoTypeId, tradeDirection, withCustoms: withCustoms ?? IsNull() }, - }); + return this.repository + .createQueryBuilder("t") + .where("t.cargo_type_id = :cargoTypeId", { cargoTypeId }) + .andWhere("t.trade_direction = :tradeDirection", { tradeDirection }) + .andWhere( + withCustoms === null + ? "t.with_customs IS NULL" + : "t.with_customs = :withCustoms", + withCustoms === null ? {} : { withCustoms }, + ) + .andWhere("COALESCE(t.ethiopian_customs_only, false) = :ethiopianCustomsOnly", { + ethiopianCustomsOnly, + }) + .getOne(); } /** @@ -49,6 +61,7 @@ export class ContractTemplatesRepository extends BaseRepository { return this.repository .createQueryBuilder("t") @@ -60,6 +73,9 @@ export class ContractTemplatesRepository extends BaseRepository = { IMPORT_BULK_CUSTOMS: "IMP_BULK_USD_FORWARDING", + IMPORT_BULK_ETHIOPIAN_CUSTOMS: "IMP_BULK_USD_FORWARDING", IMPORT_BULK_NO_CUSTOMS: "IMP_BULK_USD_TRANSPORT_ONLY", EXPORT_BULK_CUSTOMS: "EXP_BULK_USD_FORWARDING", + EXPORT_BULK_ETHIOPIAN_CUSTOMS: "EXP_BULK_USD_FORWARDING", EXPORT_BULK_NO_CUSTOMS: "EXP_BULK_USD_TRANSPORT_ONLY", INTERCITY_BULK: "DOM_BULK_USD_TRANSPORT_ONLY", IMPORT_CONTAINER_CUSTOMS: "IMP_CON_USD_FORWARDING", + IMPORT_CONTAINER_ETHIOPIAN_CUSTOMS: "IMP_CON_USD_FORWARDING", IMPORT_CONTAINER_NO_CUSTOMS: "IMP_CON_USD_TRANSPORT_ONLY", EXPORT_CONTAINER_CUSTOMS: "EXP_CON_USD_FORWARDING", + EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS: "EXP_CON_USD_FORWARDING", EXPORT_CONTAINER_NO_CUSTOMS: "EXP_CON_USD_TRANSPORT_ONLY", INTERCITY_CONTAINER: "DOM_CON_USD_TRANSPORT_ONLY", }; @@ -101,7 +105,7 @@ export class ContractTemplatesService { const direction = dto.tradeDirection; const intercity = direction === "INTERCITY"; - if (intercity && dto.withCustoms !== undefined) { + if (intercity && (dto.withCustoms !== undefined || dto.ethiopianCustomsOnly)) { throw new BadRequestException( "Intercity contracts are domestic and cross no border — they have no customs clearing variant", ); @@ -112,12 +116,24 @@ export class ContractTemplatesService { ); } const withCustoms = intercity ? null : Boolean(dto.withCustoms); + const ethiopianOnly = Boolean(dto.ethiopianCustomsOnly) && !intercity; + if (ethiopianOnly && !withCustoms) { + throw new BadRequestException( + "Ethiopian-customs-only is a customs clearing variant — it requires withCustoms to be true", + ); + } - const label = this.comboLabel(cargoType.cargoTypeName, direction, withCustoms); + const label = this.comboLabel( + cargoType.cargoTypeName, + direction, + withCustoms, + ethiopianOnly, + ); const existing = await this.repository.findByCargoCombo( dto.cargoTypeId, direction, withCustoms, + ethiopianOnly, ); if (existing) { throw new ConflictException( @@ -126,11 +142,13 @@ export class ContractTemplatesService { } const template = new ContractTemplate(); - template.code = bulkTemplateCode(cargoType.code, direction, withCustoms); + template.code = bulkTemplateCode(cargoType.code, direction, withCustoms, ethiopianOnly); template.name = dto.name ?? label; template.description = dto.description ?? null; template.documentTitle = withCustoms - ? `${cargoType.cargoTypeName} Transportation and Customs Clearance Services` + ? ethiopianOnly + ? `${cargoType.cargoTypeName} Transportation and Ethiopian Customs Clearance Services` + : `${cargoType.cargoTypeName} Transportation and Customs Clearance Services` : `${cargoType.cargoTypeName} Transportation Services`; template.whereasClauses = []; template.articles = []; @@ -138,6 +156,7 @@ export class ContractTemplatesService { template.cargoTypeId = cargoType.id; template.tradeDirection = direction; template.withCustoms = withCustoms; + template.ethiopianCustomsOnly = intercity ? null : ethiopianOnly; template.isSystem = false; try { return await this.repository.saveTemplate(template); @@ -157,18 +176,21 @@ export class ContractTemplatesService { cargoTypeName: string, direction: BulkTemplateDirection, withCustoms: boolean | null, + ethiopianCustomsOnly = false, ): string { const dir = direction.charAt(0) + direction.slice(1).toLowerCase(); const customs = withCustoms === null ? "" : withCustoms - ? ", with customs clearing" + ? ethiopianCustomsOnly + ? ", with Ethiopian customs clearing only" + : ", with customs clearing" : ", without customs clearing"; return `${cargoTypeName} Bulk Contract (${dir}${customs})`; } - /** Bulk templates only — the five seeded container templates are permanent. */ + /** Bulk templates only — the seeded container templates are permanent. */ async remove(code: string): Promise { const template = await this.getByCode(code); if (template.isSystem) { @@ -193,6 +215,7 @@ export class ContractTemplatesService { freightType?: string | null, customsClearingEnabled?: boolean | null, cargoTypeId?: string | null, + ethiopianCustomsOnly?: boolean | null, ): Promise { const isBulk = (freightType ?? "").toUpperCase().includes("BULK"); if (isBulk) { @@ -202,12 +225,16 @@ export class ContractTemplatesService { cargoTypeId, direction, direction === "INTERCITY" ? null : Boolean(customsClearingEnabled), + direction === "INTERCITY" + ? false + : Boolean(customsClearingEnabled && ethiopianCustomsOnly), ); } const code = contractTemplateCodeFor( tradeDirection, freightType, customsClearingEnabled, + ethiopianCustomsOnly, ); const template = await this.repository.findByCode(code); return template?.isActive ? template : null; diff --git a/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts b/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts index 9f7579dbd..7676749f3 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts @@ -43,6 +43,14 @@ export class CreateContractTemplateDto { @IsBoolean() withCustoms?: boolean; + @ApiPropertyOptional({ + description: + "Restricts the with-customs variant to Ethiopian-side clearing only (Djibouti stays with the Client). Requires withCustoms=true; rejected for INTERCITY", + }) + @IsOptional() + @IsBoolean() + ethiopianCustomsOnly?: boolean; + @ApiPropertyOptional({ description: "Display name (derived from the cargo type when omitted)" }) @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts index e070aa1e4..f94e5d52f 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts @@ -4,9 +4,10 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; import { CargoType } from "../../rule-engine/entities/cargo-type.entity"; /** - * The five seeded container templates (import/export split by customs - * clearing; intercity is domestic, crosses no border, so it has a single - * template). These are system rows: always present, never deletable. + * The seeded container templates (import/export split by customs-clearing + * option — full, Ethiopian-only, none; intercity is domestic, crosses no + * border, so it has a single template). These are system rows: always + * present, never deletable. * * Bulk templates are NOT seeded — staff create them per bulk cargo type * (`cargoTypeId`), trade direction (`tradeDirection`) and customs option @@ -20,18 +21,24 @@ import { CargoType } from "../../rule-engine/entities/cargo-type.entity"; * * The `_CUSTOMS` variant is issued when the contract has customs clearing * enabled (the Service Provider clears in Djibouti/Ethiopia on the Client's - * behalf); `_NO_CUSTOMS` is the transport-only paper, where the Client handles + * behalf); `_ETHIOPIAN_CUSTOMS` when the service type is Ethiopian-customs-only + * (the Service Provider clears the Ethiopian side only, Djibouti stays with the + * Client); `_NO_CUSTOMS` is the transport-only paper, where the Client handles * its own declarations. */ export const CONTRACT_TEMPLATE_CODES = [ "IMPORT_BULK_CUSTOMS", + "IMPORT_BULK_ETHIOPIAN_CUSTOMS", "IMPORT_BULK_NO_CUSTOMS", "EXPORT_BULK_CUSTOMS", + "EXPORT_BULK_ETHIOPIAN_CUSTOMS", "EXPORT_BULK_NO_CUSTOMS", "INTERCITY_BULK", "IMPORT_CONTAINER_CUSTOMS", + "IMPORT_CONTAINER_ETHIOPIAN_CUSTOMS", "IMPORT_CONTAINER_NO_CUSTOMS", "EXPORT_CONTAINER_CUSTOMS", + "EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS", "EXPORT_CONTAINER_NO_CUSTOMS", "INTERCITY_CONTAINER", ] as const; @@ -66,6 +73,7 @@ export function contractTemplateCodeFor( tradeDirection?: string | null, freightType?: string | null, customsClearingEnabled?: boolean | null, + ethiopianCustomsOnly?: boolean | null, ): ContractTemplateCode { const direction = tradeDirection === "IMPORT" @@ -78,7 +86,11 @@ export function contractTemplateCodeFor( if (direction === "INTERCITY") { return `INTERCITY_${freight}` as ContractTemplateCode; } - const customs = customsClearingEnabled ? "CUSTOMS" : "NO_CUSTOMS"; + const customs = customsClearingEnabled + ? ethiopianCustomsOnly + ? "ETHIOPIAN_CUSTOMS" + : "CUSTOMS" + : "NO_CUSTOMS"; return `${direction}_${freight}_${customs}` as ContractTemplateCode; } @@ -107,9 +119,16 @@ export function bulkTemplateCode( cargoCode: string, direction: BulkTemplateDirection, withCustoms: boolean | null, + ethiopianCustomsOnly = false, ): string { const suffix = - direction === "INTERCITY" ? "" : withCustoms ? "_CUSTOMS" : "_NO_CUSTOMS"; + direction === "INTERCITY" + ? "" + : withCustoms + ? ethiopianCustomsOnly + ? "_ETHIOPIAN_CUSTOMS" + : "_CUSTOMS" + : "_NO_CUSTOMS"; return `BULK_${direction}_${cargoCode}${suffix}`.toUpperCase(); } @@ -162,7 +181,15 @@ export class ContractTemplate extends BaseEntity { @Column({ name: "with_customs", type: "boolean", nullable: true }) withCustoms?: boolean | null; - /** The five seeded container templates — cannot be deleted. */ + /** + * Bulk templates only: the with-customs variant restricted to Ethiopian-side + * clearing (Djibouti stays with the Client). Only meaningful when + * `withCustoms` is true; null/false otherwise. + */ + @Column({ name: "ethiopian_customs_only", type: "boolean", nullable: true }) + ethiopianCustomsOnly?: boolean | null; + + /** The seeded container templates — cannot be deleted. */ @Column({ name: "is_system", type: "boolean", default: false }) isSystem!: boolean; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 051d0eabe..f5e00f503 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -11,6 +11,7 @@ import { import { DataSource } from 'typeorm'; import { OnEvent } from '@nestjs/event-emitter'; import { insertWithGeneratedReference } from '@edr/api-common'; +import { CargoUnitOfMeasure } from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; @@ -28,6 +29,7 @@ import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-s import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { eatDay } from '../train-scheduling/batch-window.util'; +import { bulkTonsPerWagon } from '../train-scheduling/train-capacity.util'; import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RuleEngineService } from '../rule-engine/rule-engine.service'; @@ -273,6 +275,8 @@ export class ContractBookingService { }); } + const bulkFields = await this.resolveBulkCargoFields(contract, dto); + // Denormalize route/direction/freight onto the booking for the scheduling engine. // Retry past a concurrent insert that grabbed the same BK sequence number. const booking = await insertWithGeneratedReference( @@ -306,8 +310,7 @@ export class ContractBookingService { cargoFreeText: dto.cargoFreeText?.trim() || null, isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'), isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'), - cargoTotalWeightVgm: this.resolveBulkTons(dto), - bulkTotalWeightTons: this.resolveBulkWeightTons(dto), + ...bulkFields, firstMilePickupAddress: contract.firstMilePickupAddress ?? null, firstMilePickupLat: contract.firstMilePickupLat ?? null, firstMilePickupLng: contract.firstMilePickupLng ?? null, @@ -853,6 +856,35 @@ export class ContractBookingService { if (!dto.scheduledDate) { throw new BadRequestException('A binding shipment day is required'); } + // Without-customs import/export: the customer's own clearing agent (name, + // email, phone) is captured per booking at completion. A resubmit may omit + // the fields and keep what the booking already stored. Customs contracts + // (GL clears) and intercity (no border) never collect an agent. + if ( + !contract.customsClearingEnabled && + contract.tradeDirection !== 'DOMESTIC' + ) { + const agentName = + dto.customsClearingAgent?.trim() || booking.customsClearingAgent || null; + const agentEmail = + dto.customsClearingAgentEmail?.trim() || + booking.customsClearingAgentEmail || + null; + const agentPhone = + dto.customsClearingAgentPhone?.trim() || + booking.customsClearingAgentPhone || + null; + if (!agentName || !agentEmail || !agentPhone) { + throw new BadRequestException( + 'Customs clearing agent name, email and phone are required to complete this booking.', + ); + } + await this.bookingsRepository.update(booking.id, { + customsClearingAgent: agentName, + customsClearingAgentEmail: agentEmail, + customsClearingAgentPhone: agentPhone, + } as never); + } // No expiry gate here on purpose: this booking was already initiated // before the contract lapsed (createUnderContract/initiateUnderContract // already checked expiry at start). Finishing an in-flight booking must @@ -955,8 +987,7 @@ export class ContractBookingService { await this.bookingsRepository.update(booking.id, { cargoTypeId: this.resolveCargoTypeId(contract, dto), cargoFreeText: dto.cargoFreeText?.trim() || null, - cargoTotalWeightVgm: this.resolveBulkTons(dto), - bulkTotalWeightTons: this.resolveBulkWeightTons(dto), + ...(await this.resolveBulkCargoFields(contract, dto)), equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto), // Completion is where the cargo — and therefore the price — is fixed, so // it is also where the billing currency is chosen. A bare instance was @@ -1533,8 +1564,10 @@ export class ContractBookingService { return probe; } - probe.cargoTotalWeightVgm = this.resolveBulkTons(dto); - probe.bulkTotalWeightTons = this.resolveBulkWeightTons(dto); + const bulkFields = await this.resolveBulkCargoFields(contract, dto); + probe.cargoTotalWeightVgm = bulkFields.cargoTotalWeightVgm; + probe.bulkTotalWeightTons = bulkFields.bulkTotalWeightTons; + probe.bulkRequestedWagons = bulkFields.bulkRequestedWagons; const cargoTypeId = this.resolveCargoTypeId(contract, dto); probe.cargoTypeId = cargoTypeId; if (cargoTypeId) { @@ -1938,6 +1971,107 @@ export class ContractBookingService { return tons > 0 ? tons : null; } + /** + * Bulk cargo columns for the booking row, resolved against the commodity's + * unit of measure: + * + * - PER_TON: `cargoTotalWeightVgm` = tons (legacy behaviour). + * - PER_ITEM: `cargoTotalWeightVgm` = item count, real tonnage in + * `bulkTotalWeightTons` (legacy behaviour). + * - NUMBER_OF_WAGONS: `cargoTotalWeightVgm` = tons, and the payload must fix + * the wagon count (customer on the portal, GL in the backoffice). The + * count is validated so each wagon's even share (tons ÷ wagons) fits what + * one wagon of this cargo may carry; the optional item count is stored as + * information only and never prices or sizes anything. + * + * Container contracts (and payloads without bulk lines) pass through with + * the legacy zero/null values. + */ + private async resolveBulkCargoFields( + contract: Contract, + dto: CreateBookingUnderContractDto, + ): Promise<{ + cargoTotalWeightVgm: number; + bulkTotalWeightTons: number | null; + bulkRequestedWagons: number | null; + bulkItemCount: number | null; + }> { + const legacy = { + cargoTotalWeightVgm: this.resolveBulkTons(dto), + bulkTotalWeightTons: this.resolveBulkWeightTons(dto), + bulkRequestedWagons: null as number | null, + bulkItemCount: null as number | null, + }; + if (contract.freightType === 'CONTAINER' || !dto.bulkLines?.length) { + return legacy; + } + const cargoTypeId = this.resolveCargoTypeId(contract, dto); + if (!cargoTypeId) return legacy; + const cargoType = await this.dataSource.getRepository(CargoType).findOne({ + where: { id: cargoTypeId }, + relations: { wagonTypes: true }, + }); + if (cargoType?.unitOfMeasure !== CargoUnitOfMeasure.NumberOfWagons) { + return legacy; + } + + const tons = dto.bulkLines.reduce( + (sum, l) => sum + Number(l.cargoWeightTons ?? 0), + 0, + ); + const items = dto.bulkLines.reduce( + (sum, l) => sum + Number(l.itemCount ?? 0), + 0, + ); + const wagons = Math.floor(Number(dto.requestedWagons ?? 0)); + if (!(wagons >= 1)) { + throw new BadRequestException( + `${cargoType.cargoTypeName} is booked by wagons — enter the number of wagons needed.`, + ); + } + if (!(tons > 0)) { + throw new BadRequestException('Cargo weight in tons is required.'); + } + this.assertWagonShareFits(cargoType, tons, wagons); + return { + cargoTotalWeightVgm: tons, + bulkTotalWeightTons: null, + bulkRequestedWagons: wagons, + bulkItemCount: items > 0 ? Math.floor(items) : null, + }; + } + + /** + * NUMBER_OF_WAGONS: block the booking outright when the even per-wagon share + * (tons ÷ requested wagons) is heavier than what ANY of the cargo's allowed + * wagon types may carry — 100T on 2 wagons is 50T each and fine on a 60T + * wagon, but 100T on 1 wagon can never ride. Cargo types with no wagon types + * configured skip the check (allocation falls back to the default rating). + */ + private assertWagonShareFits( + cargoType: CargoType, + tons: number, + wagons: number, + ): void { + const allowed = (cargoType.wagonTypes ?? []).filter( + (wt) => Number(wt.capacityTons) > 0, + ); + if (!allowed.length) return; + const maxPerWagon = Math.max( + ...allowed.map((wt) => + bulkTonsPerWagon(cargoType, wt.id, Number(wt.capacityTons)), + ), + ); + const share = tons / wagons; + if (share > maxPerWagon) { + throw new BadRequestException( + `${tons} tons across ${wagons} wagon(s) loads ${round3(share)}T per wagon, ` + + `but a wagon of this cargo carries at most ${round3(maxPerWagon)}T — ` + + `request at least ${Math.ceil(tons / maxPerWagon)} wagons.`, + ); + } + } + /** * Per-line handling counts. Each physical container carries its own hazardous * / reefer / return switch (entered next to its VGM), so the count is however @@ -2261,8 +2395,7 @@ export class ContractBookingService { contractRouteId: route?.id ?? null, originYardId: route?.originYardId ?? null, destinationYardId: route?.destinationYardId ?? null, - cargoTotalWeightVgm: this.resolveBulkTons(dto), - bulkTotalWeightTons: this.resolveBulkWeightTons(dto), + ...(await this.resolveBulkCargoFields(contract, dto)), firstMilePickupAddress: contract.firstMilePickupAddress ?? null, lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, bookingContainers: resolved.map(({ line, ct, totalVgmTons }) => diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 48c90fcbc..936176f86 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -428,6 +428,8 @@ export class ContractTransitionService { contract.customsClearingEnabled, // Bulk templates are keyed by the contract's cargo type. (contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId, + // Ethiopian-customs-only service types resolve to the Ethiopian variant. + contract.serviceType?.includesEthiopianCustomsOnly, ); if (!active) return null; return { diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index f50ca9d4d..0592dafc4 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -4,6 +4,7 @@ import { IsArray, IsBoolean, IsDateString, + IsEmail, IsIn, IsInt, IsNumber, @@ -11,6 +12,7 @@ import { IsString, IsUUID, Matches, + MaxLength, Min, ValidateNested, } from 'class-validator'; @@ -208,6 +210,19 @@ export class CreateBookingUnderContractDto { @Type(() => CreateBulkLineDto) bulkLines?: CreateBulkLineDto[]; + @ApiPropertyOptional({ + minimum: 1, + description: + 'NUMBER_OF_WAGONS bulk cargo only: how many wagons the shipment needs. ' + + 'The weight spreads evenly across them; a PER_WAGON rate bills this count. ' + + 'Required when the cargo type is measured by wagons, ignored otherwise.', + }) + @IsOptional() + @IsInt() + @Min(1) + @Transform(({ value }) => (value == null || value === '' ? undefined : Number(value))) + requestedWagons?: number; + @ApiPropertyOptional({ description: 'What the containers carry — captured per booking (container freight).', }) @@ -215,6 +230,29 @@ export class CreateBookingUnderContractDto { @IsString() cargoFreeText?: string; + @ApiPropertyOptional({ + maxLength: 200, + description: + 'Customs clearing agent name. Required at completion of a without-customs ' + + 'import/export booking (the service enforces it); ignored on customs contracts.', + }) + @IsOptional() + @IsString() + @MaxLength(200) + customsClearingAgent?: string; + + @ApiPropertyOptional({ maxLength: 200, description: 'Customs clearing agent email.' }) + @IsOptional() + @IsEmail() + @MaxLength(200) + customsClearingAgentEmail?: string; + + @ApiPropertyOptional({ maxLength: 50, description: 'Customs clearing agent phone number.' }) + @IsOptional() + @IsString() + @MaxLength(50) + customsClearingAgentPhone?: string; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/contracts/shipment-preview-parity.spec.ts b/apps/edr-freight-api/src/modules/contracts/shipment-preview-parity.spec.ts index 4152ffb30..d493147b9 100644 --- a/apps/edr-freight-api/src/modules/contracts/shipment-preview-parity.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/shipment-preview-parity.spec.ts @@ -63,6 +63,12 @@ describe('shipment preview / created booking parity', () => { resolveShipmentEquipmentReturn: () => c.equipmentReturn, resolveBulkTons: () => 0, resolveBulkWeightTons: () => 0, + resolveBulkCargoFields: async () => ({ + cargoTotalWeightVgm: 0, + bulkTotalWeightTons: null, + bulkRequestedWagons: null, + bulkItemCount: null, + }), resolveContainerTypeForSize: async () => ({ id: 'ct40', sizeFt: 40 }), handlingCounts: () => ({ hazardousQuantity: 0, diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts index ee64768c2..fd7754844 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts @@ -1,7 +1,12 @@ import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity'; -/** How the bulk commodity a rate is scoped to is counted (cargo_types.unit_of_measure). */ -export type CargoUom = 'PER_TON' | 'PER_ITEM' | null | undefined; +/** + * How the bulk commodity a rate is scoped to is counted + * (cargo_types.unit_of_measure). NUMBER_OF_WAGONS cargo is weighed in tons and + * offers the same PER_TON / PER_WAGON units as PER_TON cargo — only the + * booking form (which also asks for a wagon count) treats it differently. + */ +export type CargoUom = 'PER_TON' | 'PER_ITEM' | 'NUMBER_OF_WAGONS' | null | undefined; /** * Units billed against a booking's bulk quantity. That quantity is recorded in diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index fdd76861d..a1f927a77 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -21,6 +21,19 @@ export const TRAIN_SCHEDULE_STATUSES = [ export type TrainScheduleStatus = (typeof TRAIN_SCHEDULE_STATUSES)[number]; +/** One clicked loading or unloading window at a yard (ISO timestamps). */ +export interface StationWorkPhaseLog { + startedAt?: string | null; + endedAt?: string | null; + startedByUserId?: string | null; + endedByUserId?: string | null; +} + +export interface StationWorkLog { + loading?: StationWorkPhaseLog; + unloading?: StationWorkPhaseLog; +} + @Entity({ schema: 'freight', name: 'train_schedules' }) @Index(['scheduledDepartureDate']) @Index(['status']) @@ -157,6 +170,15 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'planned_wagon_real_cuts', type: 'jsonb', nullable: true }) plannedWagonRealCuts?: string[] | null; + /** + * Per-station loading/unloading time windows, clicked by yard operators: + * `{ [yardId]: { loading?: {...}, unloading?: {...} } }`. Booking load/unload + * is gated on the matching window having been STARTED at that yard; end is + * informational (elapsed time reporting). ISO strings, editable after the fact. + */ + @Column({ name: 'station_work_logs', type: 'jsonb', nullable: true }) + stationWorkLogs?: Record | null; + /** OPEN = accepting/holding bookings; FULL = train filled; CLOSED = manually closed. Orthogonal to `status`. */ @Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' }) bookingWindowStatus!: string; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 5c76b7803..9051bb816 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -241,6 +241,45 @@ describe('BookingBatchService — PAID reconcile', () => { ).not.toHaveBeenCalled(); }); + it('ensurePaidBookingAllocated never re-places a MANUAL_ONLY booking (removed from a train by staff)', async () => { + dataSource.getRepository().findOne.mockResolvedValue({ + ...paidBooking, + trainScheduleId: null, + schedulingStatus: 'MANUAL_ONLY', + } as unknown as Booking); + + await service.ensurePaidBookingAllocated(bookingId); + + expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled(); + expect(trainSchedulingService.tryAutoWagonAllocation).not.toHaveBeenCalled(); + expect(dataSource.getRepository().update).not.toHaveBeenCalled(); + }); + + it('ensurePaidBookingAllocated skips a MANUAL_ONLY booking even when still pinned to a schedule', async () => { + dataSource.getRepository().findOne.mockResolvedValue({ + ...paidBooking, + schedulingStatus: 'MANUAL_ONLY', + } as unknown as Booking); + + await service.ensurePaidBookingAllocated(bookingId); + + expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled(); + expect(trainSchedulingService.tryAutoWagonAllocation).not.toHaveBeenCalled(); + }); + + it('reconcilePaidUnlinked leaves MANUAL_ONLY bookings alone', async () => { + bookingsRepository.findPaidUnlinkedForSchedule.mockResolvedValue([ + { ...paidBooking, schedulingStatus: 'MANUAL_ONLY' }, + ]); + + await service.reconcilePaidUnlinked(scheduleId); + + expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled(); + expect( + trainSchedulingService.previewPaidBookingWagonShortage, + ).not.toHaveBeenCalled(); + }); + it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => { const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(0); const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 625ccfda7..d787f737d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -76,7 +76,7 @@ import { bookingCargoTons, bulkItemsFitFor, bulkItemWagonsRequired, - bulkTonsPerWagon, + bulkTonsPerWagonFor, bookingGrossWeightTons, deriveTrainCapacityFromLocomotive, sizePartialOfferWagons, @@ -577,6 +577,11 @@ export class BookingBatchService implements OnModuleInit { // train 30s after being cancelled. Never resurrect a dead booking. if (["CANCELLED", "EXPIRED", "REJECTED", "COMPLETED"].includes(booking.status)) return; + // Staff removed this booking from a train (dispatch left-behind / manual + // unassign) — every auto-allocation rescue below must leave it alone, or + // the next document review / sweep silently retakes the space it was + // pulled from. Only a manual staff assignment may re-place it. + if (booking.schedulingStatus === "MANUAL_ONLY") return; if (!booking.trainScheduleId) { // A paid booking with no train is money taken and nothing boarding. The // hold was expired before the payment landed (webhook lag beat the @@ -1553,6 +1558,8 @@ export class BookingBatchService implements OnModuleInit { for (const booking of unlinked) { // Held on purpose (paid, no wagon free) — the cron must not undo it. if (booking.schedulingStatus === "WAITING_FOR_WAGON") continue; + // Removed from a train by staff — manual re-assignment only. + if (booking.schedulingStatus === "MANUAL_ONLY") continue; if (await this.holdIfWagonShort(scheduleId, booking)) continue; await this.allocate(scheduleId, booking, "paid"); this.logger.log( @@ -2884,7 +2891,8 @@ export class BookingBatchService implements OnModuleInit { .map((o) => ({ ...o, free: c.stock?.availableFor([o.wagonTypeId], leg) ?? 0, - takePerWagon: bulkTonsPerWagon( + takePerWagon: bulkTonsPerWagonFor( + booking, booking.cargoType, o.wagonTypeId, o.dims.capacityTons, @@ -4722,7 +4730,8 @@ export class BookingBatchService implements OnModuleInit { const cargoTons = bookingCargoTons(booking); // PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T // wagon), so divide by the cap where one is configured for this type. - const tonsPerWagon = bulkTonsPerWagon( + const tonsPerWagon = bulkTonsPerWagonFor( + booking, booking.cargoType, booking.cargoType?.wagonTypes?.[0]?.id, capacityTons, @@ -4798,7 +4807,8 @@ export class BookingBatchService implements OnModuleInit { const wagonTypeId = o.wagonTypeId as string; // Each type sized on its OWN per-wagon tonnage cap, not just its rating // — a type capped lower swallows less per wagon. - const tonsPerWagon = bulkTonsPerWagon( + const tonsPerWagon = bulkTonsPerWagonFor( + booking, booking.cargoType, wagonTypeId, o.dims.capacityTons, @@ -5210,7 +5220,8 @@ export class BookingBatchService implements OnModuleInit { .map((o) => ({ ...o, free: stock.availableFor([o.wagonTypeId], leg), - takePerWagon: bulkTonsPerWagon( + takePerWagon: bulkTonsPerWagonFor( + booking, booking.cargoType, o.wagonTypeId, o.dims.capacityTons, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index 56b28fb7f..c7256d82d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -18,7 +18,11 @@ import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon- import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { Yard } from '../rule-engine/entities/yard.entity'; import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; -import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { + StationWorkLog, + StationWorkPhaseLog, + TrainSchedule, +} from '../train-schedules/entities/train-schedule.entity'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; import { Wagon } from '../wagons/entities/wagon.entity'; @@ -77,6 +81,7 @@ export class BookingJourneyService { ); } await this.assertTrainAtYard(schedule, booking.originYardId, 'origin'); + this.assertStationWorkStarted(schedule, booking.originYardId, 'loading'); await this.assertYardCanHandleCargo(booking, booking.originYardId, 'origin'); // Export cargo must be in the warehouse with a GRN before it can be loaded, // however it arrived and whatever it is allocated to. @@ -166,6 +171,7 @@ export class BookingJourneyService { ); } await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination'); + this.assertStationWorkStarted(schedule, booking.destinationYardId, 'unloading'); await this.assertYardCanHandleCargo(booking, booking.destinationYardId, 'destination'); // Intercity has no clearance/delivery tail — unloading completes it. Import/ @@ -294,6 +300,9 @@ export class BookingJourneyService { // dispatch — assertTrainAtYard allows origin loading in that state, so // the UI position must agree or origin Load buttons grey out wrongly. trainAtYardId: latest?.yardId ?? schedule.originStationId, + // Per-yard loading/unloading time windows — the UI derives its + // start/end buttons and the load/unload gating from these. + stationWorkLogs: schedule.stationWorkLogs ?? {}, yards: [...byYard.values()], }; } @@ -414,6 +423,62 @@ export class BookingJourneyService { return rows.map((r) => r.id); } + /** + * Record a station's loading/unloading time-window click (or edit it — an + * explicit `at` on an already-set edge overwrites the timestamp under the + * same permission that set it). Rules: end needs start, start ≤ end, no + * future times. Stored as ISO strings in train_schedules.station_work_logs. + * ponytail: read-modify-write on the jsonb — two operators clicking the same + * schedule in the same instant can clobber one edge; move to jsonb_set if + * that ever bites. + */ + async recordStationWork( + scheduleId: string, + yardId: string, + phase: 'loading' | 'unloading', + edge: 'start' | 'end', + at?: string, + userId?: string | null, + ) { + const schedule = await this.getSchedule(scheduleId); + const when = at ? new Date(at) : new Date(); + if (Number.isNaN(when.getTime())) { + throw new BadRequestException('Invalid timestamp'); + } + if (when.getTime() > Date.now() + 60_000) { + throw new BadRequestException(`${phase} ${edge} time cannot be in the future`); + } + + const logs: Record = schedule.stationWorkLogs ?? {}; + const entry: StationWorkLog = logs[yardId] ?? {}; + const ph: StationWorkPhaseLog = entry[phase] ?? {}; + + if (edge === 'end') { + if (!ph.startedAt) { + throw new BadRequestException(`Start ${phase} at this station first`); + } + if (when.getTime() < new Date(ph.startedAt).getTime()) { + throw new BadRequestException(`${phase} end cannot be before its start`); + } + ph.endedAt = when.toISOString(); + ph.endedByUserId = userId ?? null; + } else { + if (ph.endedAt && when.getTime() > new Date(ph.endedAt).getTime()) { + throw new BadRequestException(`${phase} start cannot be after its end`); + } + ph.startedAt = when.toISOString(); + ph.startedByUserId = userId ?? null; + } + + entry[phase] = ph; + logs[yardId] = entry; + await this.dataSource + .getRepository(TrainSchedule) + .update(scheduleId, { stationWorkLogs: logs }); + + return { scheduleId, yardId, phase, ...ph }; + } + // ---- helpers --------------------------------------------------------------- private async getSchedule(scheduleId: string): Promise { @@ -481,6 +546,27 @@ export class BookingJourneyService { } } + /** + * Loading/unloading a booking is only allowed inside a started work window + * at that yard — the operator must click "Start loading"/"Start unloading" + * (recordStationWork) before touching cargo. The window's END is not checked: + * a straggler booking can still be confirmed after the end click, and the + * operator can push the end time later (it's editable) if that matters. + * Lives here (not the controller) so the checkpoint-driven autoUnloadAtYard + * path is gated too — the user wants unloading fully manual. + */ + private assertStationWorkStarted( + schedule: TrainSchedule, + yardId: string, + phase: 'loading' | 'unloading', + ): void { + if (!schedule.stationWorkLogs?.[yardId]?.[phase]?.startedAt) { + throw new BadRequestException( + `Start ${phase} at this station first — the ${phase} time window has not been started`, + ); + } + } + /** * The train is "at" a yard when the latest recorded checkpoint is that yard, * or — for a booking boarding at the train's own origin — when the train has diff --git a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index 494522595..efa08a927 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -18,14 +18,19 @@ import { TrainSchedulingCreate, TrainSchedulingEditTrainNumber, TrainSchedulingLoad, + TrainSchedulingLoadingEnd, + TrainSchedulingLoadingStart, TrainSchedulingReschedule, TrainSchedulingUnload, + TrainSchedulingUnloadingEnd, + TrainSchedulingUnloadingStart, TrainSchedulingRulesManage, TrainSchedulingUpdate, TrainSchedulingView, } from "../../../common/booking-guards"; import { FREIGHT_PERMS } from "../../../seed/freight-permissions.registry"; import { AcceptIntercityBookingsDto } from "../dto/accept-intercity-bookings.dto"; +import { StationWorkDto } from "../dto/station-work.dto"; import { AssignBookingsDto } from "../dto/assign-bookings.dto"; import { AssignUnassignedBookingDto } from "../dto/assign-unassigned-booking.dto"; import { SwitchGovernmentBookingDto } from "../dto/switch-government-booking.dto"; @@ -614,6 +619,68 @@ export class TrainSchedulingController { return this.bookingJourneyService.listYardWork(id); } + @Post("schedules/:id/stations/:yardId/loading/start") + @TrainSchedulingLoadingStart() + @ApiOperation({ + summary: + "Start (or correct, via `at`) this station's loading time window — required before bookings can be loaded there", + }) + startStationLoading( + @Param("id", ParseUUIDPipe) id: string, + @Param("yardId", ParseUUIDPipe) yardId: string, + @Body() dto: StationWorkDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.bookingJourneyService.recordStationWork( + id, yardId, "loading", "start", dto.at, resolveAuthUserId(user), + ); + } + + @Post("schedules/:id/stations/:yardId/loading/end") + @TrainSchedulingLoadingEnd() + @ApiOperation({ summary: "End (or correct, via `at`) this station's loading time window" }) + endStationLoading( + @Param("id", ParseUUIDPipe) id: string, + @Param("yardId", ParseUUIDPipe) yardId: string, + @Body() dto: StationWorkDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.bookingJourneyService.recordStationWork( + id, yardId, "loading", "end", dto.at, resolveAuthUserId(user), + ); + } + + @Post("schedules/:id/stations/:yardId/unloading/start") + @TrainSchedulingUnloadingStart() + @ApiOperation({ + summary: + "Start (or correct, via `at`) this station's unloading time window — required before bookings can be unloaded there", + }) + startStationUnloading( + @Param("id", ParseUUIDPipe) id: string, + @Param("yardId", ParseUUIDPipe) yardId: string, + @Body() dto: StationWorkDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.bookingJourneyService.recordStationWork( + id, yardId, "unloading", "start", dto.at, resolveAuthUserId(user), + ); + } + + @Post("schedules/:id/stations/:yardId/unloading/end") + @TrainSchedulingUnloadingEnd() + @ApiOperation({ summary: "End (or correct, via `at`) this station's unloading time window" }) + endStationUnloading( + @Param("id", ParseUUIDPipe) id: string, + @Param("yardId", ParseUUIDPipe) yardId: string, + @Body() dto: StationWorkDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.bookingJourneyService.recordStationWork( + id, yardId, "unloading", "end", dto.at, resolveAuthUserId(user), + ); + } + @Post("schedules/:id/bookings/:bookingId/load") @TrainSchedulingLoad() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/station-work.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/station-work.dto.ts new file mode 100644 index 000000000..6100b037a --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/station-work.dto.ts @@ -0,0 +1,14 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsISO8601, IsOptional } from 'class-validator'; + +/** + * A station loading/unloading window click. `at` omitted = "now" (the button + * click); `at` given = record or correct the timestamp after the fact — same + * endpoint, same permission. + */ +export class StationWorkDto { + @ApiPropertyOptional({ description: 'ISO timestamp; omitted = now. Never in the future.' }) + @IsOptional() + @IsISO8601() + at?: string; +} 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 15cf4f592..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 @@ -170,7 +170,7 @@ describe('TrainSchedulingService', () => { wagonAllocationContainerItemsRepository as never, wagonAllocationBulkLoadsRepository as never, trainCheckpointEventsRepository as never, - {} as never, // trainCompositionRemovalLogRepository + { create: jest.fn() } as never, // trainCompositionRemovalLogRepository { autoUnloadArrivedBookings: jest.fn(), autoUnloadExportAtDjibouti: jest.fn(), @@ -182,7 +182,7 @@ describe('TrainSchedulingService', () => { { autoArriveAtFinalYard: jest.fn().mockResolvedValue([]), } as never, // bookingJourneyService - { dispatched: jest.fn(), arrived: jest.fn() } as never, // bookingNotifier + { dispatched: jest.fn(), arrived: jest.fn(), removedFromTrain: jest.fn() } as never, // bookingNotifier { getLogoImageUrl: jest.fn().mockResolvedValue(null) } as never, // logoSettings ); @@ -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' }; @@ -1640,6 +1760,85 @@ describe('TrainSchedulingService', () => { }); }); + describe('unassignBooking — MANUAL_ONLY status', () => { + const scheduleId = 'sched-rm-1'; + const removed = makeBooking('bk-rm', 'BKG-RM', 100, 5, '20FT', 5, undefined, undefined, undefined, { + status: 'PAID', + wagonsRequired: 5, + }); + + const graph = { + id: scheduleId, + status: 'DRAFT', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + trainSetId: 'ts-rm', + trainSet: { + id: 'ts-rm', + locomotive, + trainId: null, + wagons: [{ id: 'tsw-rm-1', allocations: [{ id: 'alloc-rm-1', bookingId: 'bk-rm' }] }], + }, + scheduleBookings: [{ bookingId: 'bk-rm' }], + }; + + const txManager = { + getRepository: jest.fn(() => ({ + find: jest.fn().mockResolvedValue([]), + findOne: jest.fn().mockResolvedValue(null), + update: jest.fn().mockResolvedValue(undefined), + delete: jest.fn().mockResolvedValue(undefined), + save: jest.fn().mockResolvedValue(undefined), + create: jest.fn((x: unknown) => x), + })), + }; + + beforeEach(() => { + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(graph); + bookingsRepository.findById = jest.fn().mockResolvedValue(removed); + bookingsRepository.updateSchedulingFields.mockResolvedValue(undefined); + dataSource.transaction.mockImplementation( + async (fn: (m: unknown) => Promise) => fn(txManager), + ); + jest + .spyOn( + service as never as { getTrainScheduleById: (id: string) => Promise }, + 'getTrainScheduleById' as never, + ) + .mockResolvedValue({ id: scheduleId } as never); + }); + + it('marks a staff-removed paid booking MANUAL_ONLY and fully detaches it', async () => { + await service.unassignBooking(scheduleId, 'bk-rm', 'user-1'); + + expect(bookingsRepository.updateSchedulingFields).toHaveBeenCalledWith( + 'bk-rm', + expect.objectContaining({ + schedulingStatus: 'MANUAL_ONLY', + trainScheduleId: null, + wagonsRequired: null, + }), + expect.anything(), + ); + expect(trainScheduleBookingsRepository.deleteByScheduleAndBooking).toHaveBeenCalledWith( + scheduleId, + 'bk-rm', + expect.anything(), + ); + expect(wagonAllocationContainerItemsRepository.deleteByAllocationIds).toHaveBeenCalledWith( + ['alloc-rm-1'], + expect.anything(), + ); + }); + + it('never marks ELIGIBLE — a removed booking must not rejoin the auto pool', async () => { + await service.unassignBooking(scheduleId, 'bk-rm', 'user-1'); + + const updates = bookingsRepository.updateSchedulingFields.mock.calls.map((c) => c[1]); + expect(updates.some((u) => u.schedulingStatus === 'ELIGIBLE')).toBe(false); + }); + }); + describe('updateCheckpoint — leg time correction', () => { const t = (h: number) => new Date(Date.UTC(2026, 0, 1, h)); const schedule = { 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 9c11553ff..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'; @@ -156,7 +159,7 @@ import { bookingCargoTons, bulkItemsFitFor, bulkItemWagonsRequired, - bulkTonsPerWagon, + bulkTonsPerWagonFor, consistViolations, deriveTrainCapacityFromLocomotive, combinedLocomotiveLimits, @@ -2415,7 +2418,12 @@ export class TrainSchedulingService { ); const booking = await this.bookingsRepository.findById(bookingId); - const schedulingStatus = this.resolvePostUnassignStatus(booking); + // Removed from a train by staff → MANUAL_ONLY: the paid booking must not + // be auto re-placed by any allocation sweep (it would retake the space it + // was just pulled from). Staff re-assign it manually; assign resets the + // status to SCHEDULED. Schedule *cancellation* keeps the old behaviour + // (resolvePostUnassignStatus) — there the train died, not the booking. + const schedulingStatus = SchedulingStatus.ManualOnly; // Clear the schedule pointer too: unassign fully detaches the booking from // this train. Leaving trainScheduleId set glued the booking to a schedule // that may then be dispatched/cancelled/deleted, orphaning it — the @@ -2894,6 +2902,31 @@ export class TrainSchedulingService { schedule = reloaded; } } + // Loading is tracked per station: dispatching with cargo still to board at + // the origin marks it loaded (checklist + auto-load below), so the origin's + // loading time window must have been started first — same gate the + // per-booking load endpoint enforces. + const originBoarders = await this.unloadedOriginBoarderIds( + scheduleId, + schedule.originStationId, + ); + const boardersToLoad = dto.loadedBookingIds + ? originBoarders.filter((id) => new Set(dto.loadedBookingIds).has(id)) + : originBoarders; + 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'); @@ -2939,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, @@ -3374,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, @@ -3400,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, @@ -3440,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(), }); @@ -3458,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. */ @@ -3475,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( @@ -3510,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, @@ -3517,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'); @@ -3565,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?: { @@ -3574,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) => @@ -3637,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; @@ -3648,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)} @@ -3685,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++; @@ -3763,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)}
@@ -3773,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('  |  '); + })()} +
` + : '' + } + @@ -3936,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++; @@ -3963,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) { @@ -3989,7 +4113,7 @@ export class TrainSchedulingService { - + `; }, @@ -4062,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))}
@@ -4406,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) { @@ -4446,6 +4613,10 @@ export class TrainSchedulingService { origin: stations[0]?.label ?? null, 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 — 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, @@ -4920,6 +5091,15 @@ export class TrainSchedulingService { if (schedule.status !== TrainScheduleStatusEnum.Dispatched) { throw new BadRequestException('Only DISPATCHED trains can arrive'); } + // 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, + ); // The arrival clock: the operator's entered time when arriving via the final // checkpoint (already order/future-checked there), else now. @@ -4932,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, @@ -4960,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. @@ -5306,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, @@ -6093,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) => ({ @@ -6121,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, }; } @@ -9430,6 +9646,8 @@ export class TrainSchedulingService { Booking, | 'freightType' | 'cargoTotalWeightVgm' + | 'bulkTotalWeightTons' + | 'bulkRequestedWagons' | 'wagonsRequired' | 'bookingContainers' | 'cargoType' @@ -9460,7 +9678,7 @@ export class TrainSchedulingService { const byLength = containerWagonsForLines(booking.bookingContainers ?? []); // PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T // wagon) — more wagons for the same cargo, so more tare to pull. - const tonsPerWagon = bulkTonsPerWagon(booking.cargoType, wagonTypeId, dims.capacityTons); + const tonsPerWagon = bulkTonsPerWagonFor(booking, booking.cargoType, wagonTypeId, dims.capacityTons); const byWeight = cargo > 0 && tonsPerWagon > 0 ? Math.ceil(cargo / tonsPerWagon) : 0; // Break-bulk (PER_ITEM): indivisible items occupy more wagons than raw // tonnage suggests — their tare must be pulled too (batch dimsFor parity). @@ -9592,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 @@ -9625,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 @@ -9945,6 +10195,10 @@ export class TrainSchedulingService { // Ordered corridor stops (route milestones; falls back to the two // endpoints) — lets the UI draw per-segment occupancy and label legs. stops: this.mapScheduleStops(schedule), + // Per-yard loading/unloading time windows (start/end clicks) — the + // detail page shows the origin's loading window; dispatch requires it + // started when cargo boards there. + stationWorkLogs: schedule.stationWorkLogs ?? {}, // Gross ceiling the validator holds each leg to: the set's weakest // locomotive pull limit plus its overage tolerance. Booking weightTons // above are gross too, so the strip can sum them per leg against this. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts index ed476a6e1..05f386a4c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts @@ -5,6 +5,7 @@ import { bulkItemWagonsForAllowedTypes, bulkItemWagonsRequired, bulkTonsPerWagon, + bulkTonsPerWagonFor, bulkTonWagonsForAllowedTypes, bulkTonWagonsRequired, bulkWagonsForAllowedTypes, @@ -177,6 +178,19 @@ describe('train-capacity.util', () => { expect(bulkTonWagonsForAllowedTypes(bulk(200), cargoType, 70)).toBe(3); }); + it('NUMBER_OF_WAGONS: a requested count wins over the tonnage-derived one', () => { + const req = { ...bulk(100), bulkRequestedWagons: 40 }; + // 100T on 70T wagons is 2 by tonnage — the customer asked for 40. + expect(bulkTonWagonsRequired(req, null, 'nw5', 70)).toBe(40); + expect(bulkWagonsForAllowedTypes(req, { wagonTypes: [{ id: 'nw5', capacityTons: 70 }] }, 70)).toBe(40); + // Each wagon then carries the even share, not rated capacity. + expect(bulkTonsPerWagonFor(req, null, 'nw5', 70)).toBe(2.5); + // ceil(tons / evenShare) must land exactly on the requested count. + const awkward = { ...bulk(100), bulkRequestedWagons: 3 }; + const share = bulkTonsPerWagonFor(awkward, null, 'nw5', 70); + expect(Math.ceil(100 / share)).toBe(3); + }); + it('routes PER_ITEM and PER_TON through one call', () => { expect(bulkWagonsForAllowedTypes(bulk(200), sugar, 70)).toBe(4); // PER_ITEM still wins where an item count is present. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts index 63feb6e8c..8b980be11 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -114,6 +114,43 @@ export function bookingCargoTons(booking: { ); } +/** + * Customer-requested wagon count of a NUMBER_OF_WAGONS bulk booking; 0 when + * the booking carries none (every other cargo unit). The request was validated + * against wagon capacity at booking creation, so sizing code honours it + * verbatim instead of deriving a count from tonnage. + */ +export function requestedBulkWagons(booking: { + bulkRequestedWagons?: number | string | null; +}): number { + const n = Math.floor(num(booking.bulkRequestedWagons)); + return n > 0 ? n : 0; +} + +/** + * Booking-aware {@link bulkTonsPerWagon}: a NUMBER_OF_WAGONS booking fixed its + * wagon count, so each wagon carries tons ÷ requested (the even spread the + * customer asked for), never more. Rounded UP to 3 decimals so + * ceil(tons / perWagon) lands exactly on the requested count instead of one + * over on float error. Other bookings get the cargo-type figure unchanged. + */ +export function bulkTonsPerWagonFor( + booking: Parameters[0] & { + bulkRequestedWagons?: number | string | null; + }, + cargoType: ItemFitCargoType | undefined, + wagonTypeId: string | null | undefined, + capacityTons: number | string | null | undefined, +): number { + const base = bulkTonsPerWagon(cargoType, wagonTypeId, capacityTons); + const requested = requestedBulkWagons(booking); + if (!requested) return base; + const tons = bookingCargoTons(booking); + if (!(tons > 0)) return base; + const evenShare = Math.ceil((tons / requested) * 1000) / 1000; + return base > 0 ? Math.min(base, evenShare) : evenShare; +} + /** * Wagons a break-bulk (PER_ITEM) bulk booking needs. Items are indivisible, so * floor how many whole items fit one wagon, then ceil the wagon count: @@ -184,11 +221,16 @@ export function bulkTonsPerWagon( * usable per-wagon figure, so callers can fall back as before. */ export function bulkTonWagonsRequired( - booking: Parameters[0], + booking: Parameters[0] & { + bulkRequestedWagons?: number | string | null; + }, cargoType: ItemFitCargoType | undefined, wagonTypeId: string | null | undefined, capacityTons: number | string | null | undefined, ): number { + // NUMBER_OF_WAGONS: the customer fixed the count — honour it verbatim. + const requested = requestedBulkWagons(booking); + if (requested) return requested; const perWagon = bulkTonsPerWagon(cargoType, wagonTypeId, capacityTons); const tons = bookingCargoTons(booking); if (!(perWagon > 0) || !(tons > 0)) return 0; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts index 2df626a84..b4e87d398 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts @@ -7,7 +7,7 @@ import { bookingCargoTons, bulkItemsFitFor, bulkItemWagonsRequired, - bulkTonsPerWagon, + bulkTonsPerWagonFor, bulkTonWagonsRequired, consistViolations, } from '../train-capacity.util'; @@ -193,8 +193,11 @@ export function buildBulkWagonPlan( // pool with uncapped tonnage either: its wagons stop at the cap, so 200T needs // 4 wagons and pooling it at 70T would plan 3. Capped bookings are sized on // their own cap; only genuinely uncapped tonnage pools at rated capacity. + // A NUMBER_OF_WAGONS booking is "capped" at its even share (tons ÷ requested), + // so it plans exactly the requested count. const cappedTonSlotsByBooking = bookings.map((b, i) => - itemSlotsByBooking[i] > 0 || bulkTonsPerWagon(b.cargoType, wagonType.id, capacity) >= capacity + itemSlotsByBooking[i] > 0 || + bulkTonsPerWagonFor(b, b.cargoType, wagonType.id, capacity) >= capacity ? 0 : bulkTonWagonsRequired(b, b.cargoType, wagonType.id, capacity), ); @@ -330,6 +333,7 @@ function allocateBookingsToSlots( // bookings that column is an item COUNT, not tons. remainingWeightTons: roundTons(bookingCargoTons(booking)), cargoType: booking.cargoType, + booking, })); let bookingIndex = 0; @@ -343,10 +347,17 @@ function allocateBookingsToSlots( const booking = remaining[bookingIndex]; // A PER_TON loading cap (sugar 50T on a 70T wagon) binds the FILL as well // as the wagon count — the plan reserved a wagon per capped chunk, so - // pouring rated capacity into it would leave the last wagon empty. + // pouring rated capacity into it would leave the last wagon empty. A + // NUMBER_OF_WAGONS booking fills each wagon its even share (tons ÷ + // requested) for the same reason. const takeCap = Math.min( wagonRemaining, - bulkTonsPerWagon(booking.cargoType, slot.wagonTypeId, slot.capacityTons), + bulkTonsPerWagonFor( + booking.booking, + booking.cargoType, + slot.wagonTypeId, + slot.capacityTons, + ), ); const allocatedWeightTons = roundTons( Math.min(takeCap, booking.remainingWeightTons), diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts index 449033bfc..92e4b441d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -6,6 +6,7 @@ import { bookingCargoTons, bulkItemsFitFor, bulkTonsPerWagon, + bulkTonsPerWagonFor, bulkWagonsForAllowedTypes, } from './train-capacity.util'; import { @@ -179,7 +180,7 @@ const shortageFor = ( let seatable = 0; let usedWagons = 0; for (const { wt, free } of freeByType) { - const perWagon = bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons)); + const perWagon = bulkTonsPerWagonFor(booking, booking.cargoType, wt.id, Number(wt.capacityTons)); if (!(perWagon > 0) || free <= 0) continue; seatable += free * perWagon; usedWagons += free; @@ -188,7 +189,7 @@ const shortageFor = ( const bestPerWagon = Math.max( 1, ...candidates.map((wt) => - bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons)), + bulkTonsPerWagonFor(booking, booking.cargoType, wt.id, Number(wt.capacityTons)), ), ); return { @@ -583,7 +584,8 @@ export function planWagonsWithStock(params: { if (perItem ? remainingItems <= 0 : remainingWeight <= 0) break; const wagonType = candidates.find((wt) => wt.id === open.slot.wagonTypeId); if (!wagonType) continue; - const room = bulkTonsPerWagon( + const room = bulkTonsPerWagonFor( + booking, booking.cargoType, open.slot.wagonTypeId, Number(open.slot.capacityTons), @@ -640,7 +642,20 @@ export function planWagonsWithStock(params: { openedSlot.freeItems = itemBudgetOf(openedSlot) - takeItems; remainingItems -= takeItems; } else { - take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight)); + // NUMBER_OF_WAGONS: each wagon takes the even share (tons / requested), + // not the full per-wagon cap — the loop then opens exactly that count. + take = roundTons( + Math.min( + openedSlot.freeCapacityTons, + bulkTonsPerWagonFor( + booking, + booking.cargoType, + openedSlot.slot.wagonTypeId, + openedSlot.slot.capacityTons, + ), + remainingWeight, + ), + ); } addAllocation( openedSlot.slot, diff --git a/apps/edr-freight-api/src/modules/trains/dto/wagon-detach-request.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/wagon-detach-request.dto.ts new file mode 100644 index 000000000..afe219bfa --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/dto/wagon-detach-request.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; + +import { WagonDetachRequestAction } from '../entities/wagon-detach-request.entity'; + +export class CreateWagonDetachRequestDto { + @ApiProperty({ + enum: WagonDetachRequestAction, + description: 'What approval is being asked for: a plain detach, or detach + MAINTENANCE.', + }) + @IsEnum(WagonDetachRequestAction) + action!: WagonDetachRequestAction; + + @ApiProperty({ + description: 'Why the wagon must leave the scheduled consist. Shown to the approver.', + maxLength: 500, + }) + @IsString() + @IsNotEmpty() + @MaxLength(500) + reason!: string; +} + +export class DecideWagonDetachRequestDto { + @ApiPropertyOptional({ + description: 'Decision note — required when rejecting, optional when approving.', + maxLength: 500, + }) + @IsOptional() + @IsString() + @MaxLength(500) + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/trains/entities/wagon-detach-request.entity.ts b/apps/edr-freight-api/src/modules/trains/entities/wagon-detach-request.entity.ts new file mode 100644 index 000000000..acaaed464 --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/entities/wagon-detach-request.entity.ts @@ -0,0 +1,69 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +export enum WagonDetachRequestAction { + Detach = 'DETACH', + Maintenance = 'MAINTENANCE', +} + +export enum WagonDetachRequestStatus { + Pending = 'PENDING', + Approved = 'APPROVED', + Rejected = 'REJECTED', +} + +/** + * Approval gate for detaching a wagon (or sending it to maintenance) from a + * train that is on a SCHEDULED run. + * + * A draft-schedule or unscheduled train is edited freely; once the run is + * SCHEDULED, pulling a wagon out changes a departure customers already booked + * against, so it becomes a two-person action: one staffer requests with a + * reason, another (holding trains:approve_wagon_detach) approves — approval + * executes the detach immediately. Rows are never deleted: decided rows are + * the audit trail of who asked, who decided, and why. + */ +@Entity({ schema: 'freight', name: 'wagon_detach_requests' }) +@Index(['trainId']) +@Index(['trainId', 'status']) +export class WagonDetachRequest extends BaseEntity { + @Column({ name: 'train_id', type: 'uuid' }) + trainId!: string; + + @Column({ name: 'wagon_id', type: 'uuid' }) + wagonId!: string; + + /** Snapshot — the audit trail must read correctly if the wagon is renumbered or deleted. */ + @Column({ name: 'wagon_number', type: 'varchar', length: 50 }) + wagonNumber!: string; + + @Column({ name: 'action', type: 'varchar', length: 20 }) + action!: WagonDetachRequestAction; + + @Column({ name: 'reason', type: 'varchar', length: 500 }) + reason!: string; + + @Column({ + name: 'status', + type: 'enum', + enum: WagonDetachRequestStatus, + enumName: 'wagon_detach_requests_status_enum', + default: WagonDetachRequestStatus.Pending, + }) + status!: WagonDetachRequestStatus; + + /** IAM user id of the requester. The approver must be a different person. */ + @Column({ name: 'requested_by', type: 'uuid', nullable: true }) + requestedBy?: string | null; + + /** IAM user id of the approver/rejecter; null while pending. */ + @Column({ name: 'decided_by', type: 'uuid', nullable: true }) + decidedBy?: string | null; + + @Column({ name: 'decided_at', type: 'timestamptz', nullable: true }) + decidedAt?: Date | null; + + /** Required on reject, optional on approve. */ + @Column({ name: 'decision_note', type: 'varchar', length: 500, nullable: true }) + decisionNote?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts index 21431a1a4..46c95de95 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts @@ -25,6 +25,10 @@ import { BuildTrainDto } from './dto/build-train.dto'; import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto'; import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto'; import { SendWagonToMaintenanceDto } from './dto/send-wagon-to-maintenance.dto'; +import { + CreateWagonDetachRequestDto, + DecideWagonDetachRequestDto, +} from './dto/wagon-detach-request.dto'; import { UpdateTrainDetailsDto } from './dto/update-train-details.dto'; import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto'; import { UpdateTrainYardDto } from './dto/update-train-yard.dto'; @@ -47,6 +51,7 @@ import { TrainBuilderService } from './train-builder.service'; FREIGHT_PERMS.trains.changeWagonYard, FREIGHT_PERMS.trains.toggleActive, FREIGHT_PERMS.trains.disband, + FREIGHT_PERMS.trains.approveWagonDetach, ]) export class TrainBuilderController { constructor(private readonly trainBuilderService: TrainBuilderService) {} @@ -206,6 +211,74 @@ export class TrainBuilderController { ); } + @Get(':id/detach-requests') + @ApiOperation({ + summary: + 'Detach/maintenance approval requests of this train, newest first — pending and decided alike (the audit trail)', + }) + detachRequests(@Param('id', ParseUUIDPipe) id: string) { + return this.trainBuilderService.listDetachRequests(id); + } + + @Post(':id/wagons/:wagonId/detach-requests') + @FleetManage(FREIGHT_PERMS.trains.assignWagons) + @ApiOperation({ + summary: + 'Request approval to detach a wagon (or send it to maintenance) while the train is on a SCHEDULED run', + }) + createDetachRequest( + @Param('id', ParseUUIDPipe) id: string, + @Param('wagonId', ParseUUIDPipe) wagonId: string, + @Body() dto: CreateWagonDetachRequestDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.trainBuilderService.createDetachRequest( + id, + wagonId, + dto, + resolveAuthUserId(user), + ); + } + + @Post(':id/detach-requests/:requestId/approve') + @FleetManage(FREIGHT_PERMS.trains.approveWagonDetach) + @ApiOperation({ + summary: + 'Approve a detach/maintenance request — the detach executes immediately; the approver must not be the requester', + }) + approveDetachRequest( + @Param('id', ParseUUIDPipe) id: string, + @Param('requestId', ParseUUIDPipe) requestId: string, + @CurrentUser() user: AuthUserPayload, + @Body() dto?: DecideWagonDetachRequestDto, + ) { + return this.trainBuilderService.decideDetachRequest( + id, + requestId, + 'APPROVE', + resolveAuthUserId(user), + dto?.note, + ); + } + + @Post(':id/detach-requests/:requestId/reject') + @FleetManage(FREIGHT_PERMS.trains.approveWagonDetach) + @ApiOperation({ summary: 'Reject a detach/maintenance request — a note explaining why is required' }) + rejectDetachRequest( + @Param('id', ParseUUIDPipe) id: string, + @Param('requestId', ParseUUIDPipe) requestId: string, + @CurrentUser() user: AuthUserPayload, + @Body() dto: DecideWagonDetachRequestDto, + ) { + return this.trainBuilderService.decideDetachRequest( + id, + requestId, + 'REJECT', + resolveAuthUserId(user), + dto.note, + ); + } + @Post(':id/reorder-wagons') @FleetManage(FREIGHT_PERMS.trains.assignWagons) @ApiOperation({ summary: 'Persist a drag-reorder of the full consist' }) diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index d7cfc29c5..09f6559f7 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -30,8 +30,14 @@ import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto'; import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto'; import { UpdateTrainDetailsDto } from './dto/update-train-details.dto'; import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto'; +import { CreateWagonDetachRequestDto } from './dto/wagon-detach-request.dto'; import { TrainLocomotive } from './entities/train-locomotive.entity'; import { Train } from './entities/train.entity'; +import { + WagonDetachRequest, + WagonDetachRequestAction, + WagonDetachRequestStatus, +} from './entities/wagon-detach-request.entity'; import { buildPaginationMeta, normalizePagination, @@ -751,32 +757,43 @@ export class TrainBuilderService { /** Detach one wagon and close the sequence gap it leaves. */ async removeWagon(id: string, wagonId: string, userId?: string | null) { const pending = await this.dataSource.transaction(async (manager) => { - const train = await this.getEditableTrain(manager, id); - const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); - if (!wagon || wagon.trainId !== train.id) { - throw new NotFoundException(`Wagon ${wagonId} is not part of this train`); - } - await this.assertDetachableAndReleaseStaleSlots(manager, wagon); - await manager.getRepository(Wagon).update(wagon.id, { - trainId: null, - sequenceNumber: null, - status: WagonStatus.Available, - importTrainNumber: null, - exportTrainNumber: null, - }); - await this.resequenceWagons(manager, train.id); - return this.syncLiveScheduleAfterConsistChange( - manager, - train.id, - [{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }], - userId ?? null, - wagon.currentYardId ?? train.currentYardId ?? null, - ); + await this.assertDetachNeedsNoApproval(manager, id); + return this.removeWagonCore(manager, id, wagonId, userId); }); await this.reconcileWindowAfterConsistChange(pending); return this.getComposition(id); } + /** Transactional body of removeWagon — also runs under an approved detach request. */ + private async removeWagonCore( + manager: EntityManager, + id: string, + wagonId: string, + userId?: string | null, + ): Promise { + const train = await this.getEditableTrain(manager, id); + const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); + if (!wagon || wagon.trainId !== train.id) { + throw new NotFoundException(`Wagon ${wagonId} is not part of this train`); + } + await this.assertDetachableAndReleaseStaleSlots(manager, wagon); + await manager.getRepository(Wagon).update(wagon.id, { + trainId: null, + sequenceNumber: null, + status: WagonStatus.Available, + importTrainNumber: null, + exportTrainNumber: null, + }); + await this.resequenceWagons(manager, train.id); + return this.syncLiveScheduleAfterConsistChange( + manager, + train.id, + [{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }], + userId ?? null, + wagon.currentYardId ?? train.currentYardId ?? null, + ); + } + /** * Detach one wagon AND flag it for maintenance: it leaves the consist and * moves to MAINTENANCE status (not AVAILABLE), so it is not re-coupled until @@ -789,6 +806,22 @@ export class TrainBuilderService { note?: string | null, ) { const pending = await this.dataSource.transaction(async (manager) => { + await this.assertDetachNeedsNoApproval(manager, id); + return this.sendWagonToMaintenanceCore(manager, id, wagonId, userId, note); + }); + await this.reconcileWindowAfterConsistChange(pending); + return this.getComposition(id); + } + + /** Transactional body of sendWagonToMaintenance — also runs under an approved request. */ + private async sendWagonToMaintenanceCore( + manager: EntityManager, + id: string, + wagonId: string, + userId?: string | null, + note?: string | null, + ): Promise { + { const train = await this.getEditableTrain(manager, id); const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); if (!wagon || wagon.trainId !== train.id) { @@ -851,6 +884,191 @@ export class TrainBuilderService { userId ?? null, yardId, ); + } + } + + /** + * Direct-detach guard: while this train carries a live SCHEDULED run, + * removing a wagon changes a departure customers already booked against, so + * it is a two-person action — refuse here and point at the request flow. + * DRAFT stays freely editable; DISPATCHED is already frozen by + * getEditableTrain (the train is IN_SERVICE). + */ + private async assertDetachNeedsNoApproval( + manager: EntityManager, + trainId: string, + ): Promise { + const scheduled = await this.findScheduledRun(manager, trainId); + if (scheduled) { + throw new ConflictException( + `Train is on scheduled run ${scheduled.reference ?? scheduled.id} — detaching a wagon needs an approved detach request`, + ); + } + } + + private async findScheduledRun( + manager: EntityManager, + trainId: string, + ): Promise<{ id: string; reference: string | null } | null> { + const rows: { id: string; reference: string | null }[] = await manager.query( + `SELECT ts.id, ts.reference + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + WHERE tset.train_id = $1 + AND ts.status = 'SCHEDULED' + AND ts.deleted_at IS NULL + LIMIT 1`, + [trainId], + ); + return rows[0] ?? null; + } + + /** + * File a detach/maintenance approval request for a wagon on a SCHEDULED + * train. The request carries the reason; a different staffer with + * trains:approve_wagon_detach decides it (approval executes the detach). + */ + async createDetachRequest( + id: string, + wagonId: string, + dto: CreateWagonDetachRequestDto, + userId?: string | null, + ) { + return this.dataSource.transaction(async (manager) => { + const train = await this.getEditableTrain(manager, id); + const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); + if (!wagon || wagon.trainId !== train.id) { + throw new NotFoundException(`Wagon ${wagonId} is not part of this train`); + } + const scheduled = await this.findScheduledRun(manager, train.id); + if (!scheduled) { + throw new ConflictException( + 'This train has no SCHEDULED run — detach the wagon directly, no approval needed', + ); + } + // Refuse up front what an approval could never execute (booked + // allocations pin the wagon) — but release nothing yet: slots are only + // touched when the approved detach actually runs. + await this.assertDetachableAndReleaseStaleSlots(manager, wagon, { checkOnly: true }); + const repo = manager.getRepository(WagonDetachRequest); + const open = await repo.findOne({ + where: { trainId: train.id, wagonId: wagon.id, status: WagonDetachRequestStatus.Pending }, + }); + if (open) { + throw new ConflictException( + `Wagon ${wagon.wagonNumber} already has a pending detach request`, + ); + } + return repo.save( + repo.create({ + trainId: train.id, + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + action: dto.action, + reason: dto.reason.trim(), + requestedBy: userId ?? null, + }), + ); + }); + } + + /** All detach/maintenance requests of this train, newest first — the approval audit trail. */ + async listDetachRequests(trainId: string) { + const rows: Array<{ + id: string; + wagonId: string; + wagonNumber: string; + action: string; + reason: string; + status: string; + requestedById: string | null; + requestedBy: string | null; + requestedAt: Date; + decidedBy: string | null; + decidedAt: Date | null; + decisionNote: string | null; + }> = await this.dataSource.query( + `SELECT r.id, + r.wagon_id AS "wagonId", + r.wagon_number AS "wagonNumber", + r.action, + r.reason, + r.status, + r.requested_by AS "requestedById", + COALESCE(ru.username, ru.email) AS "requestedBy", + r.created_at AS "requestedAt", + COALESCE(du.username, du.email) AS "decidedBy", + r.decided_at AS "decidedAt", + r.decision_note AS "decisionNote" + FROM freight.wagon_detach_requests r + LEFT JOIN iam.users ru ON ru.id = r.requested_by + LEFT JOIN iam.users du ON du.id = r.decided_by + WHERE r.train_id = $1 + AND r.deleted_at IS NULL + ORDER BY r.created_at DESC + LIMIT 100`, + [trainId], + ); + return rows; + } + + /** + * Decide a pending request. Approve executes the detach (or maintenance + * move) in the same transaction that stamps the decision, so an approved row + * can never exist without its detach having happened. The requester cannot + * approve their own request; a rejection must carry a note. + */ + async decideDetachRequest( + id: string, + requestId: string, + decision: 'APPROVE' | 'REJECT', + userId?: string | null, + note?: string | null, + ) { + const pending = await this.dataSource.transaction(async (manager) => { + const repo = manager.getRepository(WagonDetachRequest); + const request = await repo.findOne({ + where: { id: requestId, trainId: id }, + lock: { mode: 'pessimistic_write' }, + }); + if (!request) { + throw new NotFoundException(`Detach request ${requestId} not found on this train`); + } + if (request.status !== WagonDetachRequestStatus.Pending) { + throw new ConflictException( + `This request was already ${request.status.toLowerCase()}`, + ); + } + const decisionNote = note?.trim() || null; + if (decision === 'REJECT') { + if (!decisionNote) { + throw new BadRequestException('A note explaining the rejection is required'); + } + await repo.update(request.id, { + status: WagonDetachRequestStatus.Rejected, + decidedBy: userId ?? null, + decidedAt: new Date(), + decisionNote, + }); + return null; + } + // The 4-eyes point of the gate: requester and approver are different people. + if (request.requestedBy && userId && request.requestedBy === userId) { + throw new ConflictException( + 'You filed this request — a different staff member must approve it', + ); + } + const pendingCheck = + request.action === WagonDetachRequestAction.Maintenance + ? await this.sendWagonToMaintenanceCore(manager, id, request.wagonId, userId, request.reason) + : await this.removeWagonCore(manager, id, request.wagonId, userId); + await repo.update(request.id, { + status: WagonDetachRequestStatus.Approved, + decidedBy: userId ?? null, + decidedAt: new Date(), + decisionNote, + }); + return pendingCheck; }); await this.reconcileWindowAfterConsistChange(pending); return this.getComposition(id); @@ -893,6 +1111,7 @@ export class TrainBuilderService { private async assertDetachableAndReleaseStaleSlots( manager: EntityManager, wagon: Wagon, + opts: { checkOnly?: boolean } = {}, ): Promise { const rows: { id: string; train_set_id: string; status: string; allocs: string }[] = await manager.query( @@ -915,6 +1134,7 @@ export class TrainBuilderService { `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`, ); } + if (opts.checkOnly) return; await manager.getRepository(TrainSetWagon).delete(rows.map((r) => r.id)); for (const trainSetId of [...new Set(rows.map((r) => r.train_set_id))]) { const remaining = await manager.getRepository(TrainSetWagon).find({ diff --git a/apps/edr-freight-api/src/modules/trains/trains.module.ts b/apps/edr-freight-api/src/modules/trains/trains.module.ts index 6009ebef7..1f50681cd 100644 --- a/apps/edr-freight-api/src/modules/trains/trains.module.ts +++ b/apps/edr-freight-api/src/modules/trains/trains.module.ts @@ -4,13 +4,17 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; import { TrainLocomotive } from './entities/train-locomotive.entity'; import { Train } from './entities/train.entity'; +import { WagonDetachRequest } from './entities/wagon-detach-request.entity'; import { TrainBuilderController } from './train-builder.controller'; import { TrainBuilderService } from './train-builder.service'; import { TrainsController } from './trains.controller'; import { TrainsService } from './trains.service'; @Module({ - imports: [TypeOrmModule.forFeature([Train, TrainLocomotive]), TrainSchedulingModule], + imports: [ + TypeOrmModule.forFeature([Train, TrainLocomotive, WagonDetachRequest]), + TrainSchedulingModule, + ], controllers: [TrainsController, TrainBuilderController], providers: [TrainsService, TrainBuilderService], exports: [TrainsService, TrainBuilderService], diff --git a/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts b/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts index 6255c90fc..5ddc99cca 100644 --- a/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts +++ b/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts @@ -4,7 +4,7 @@ import type { } from "../../modules/contract-templates/entities/contract-template.entity"; /** - * Default article packs for the ten contract templates, transcribed from the + * Default article packs for the fourteen contract templates, transcribed from the * signed EDR contract documents (test/contrat_docs). Article bodies use the * dynamic-article text format: one clause per line, "- " prefix for bullets * nested under the previous clause, single-line body = plain paragraph. @@ -23,7 +23,8 @@ export interface ContractTemplateSeed { /** * A base pack keyed by direction/freight only. Each one is transcribed from a * signed EDR contract and is split at the bottom of this file into the - * `_CUSTOMS` / `_NO_CUSTOMS` pair the template table actually stores. + * `_CUSTOMS` / `_ETHIOPIAN_CUSTOMS` / `_NO_CUSTOMS` trio the template table + * actually stores. */ type ContractTemplateBase = Omit; @@ -883,7 +884,35 @@ Settle assessed duties and taxes within the period notified by the Service Provi ), ]; -/** Build the stored `_CUSTOMS` / `_NO_CUSTOMS` pair for one base pack. */ +/** + * Articles appended to the `_ETHIOPIAN_CUSTOMS` variant: the Service Provider + * clears the Ethiopian side only, while Djibouti clearing stays with the + * Client. Article ids match the full-customs pack so downstream checks treat + * both as customs-clearing variants. + */ +const ETHIOPIAN_CUSTOMS_ARTICLES: Array> = [ + a( + "customs-clearing", + "Ethiopian Customs Clearing Services", + `The Service Provider shall carry out customs clearing on behalf of the Client for the cargo covered by this Agreement at the customs stations of Ethiopia only, including declaration, lodgement, and follow-up as applicable to the agreed corridor. +Customs clearing at Djibouti is not included in this Agreement and remains the sole responsibility of the Client. +The Service Provider shall act only within the authority granted by the Client and shall not amend a declaration without the Client's written instruction. +Customs duties, taxes, and any government charges assessed on the cargo remain payable by the Client and are not included in the freight price; the Service Provider shall settle them on the Client's behalf only where the Client has placed the corresponding funds in advance. +The Service Provider shall hand over all customs documents obtained in the course of clearing to the Client upon completion of each shipment.`, + ), + a( + "customs-client-duties", + "Client Obligations for Ethiopian Customs Clearing", + `Grant the Service Provider a duly signed and stamped power of attorney authorising it to act as the Client's customs agent in Ethiopia for the duration of this Agreement. +Complete customs clearing at Djibouti and deliver the cargo customs-cleared on the Djibouti side, together with the supporting release documents, in time for the scheduled railway loading. +Submit every document required for declaration (commercial invoice, packing list, bill of lading or airway bill, permits, certificates of origin, and any authority-specific licence) within one (1) calendar day of the Service Provider's request. +Warrant that the declared description, quantity, value, and tariff classification of the cargo are complete and accurate. +Bear any penalty, demurrage, storage, or re-inspection cost arising from incorrect, incomplete, or late Client-supplied information or documentation, or from delayed Djibouti-side clearing. +Settle assessed duties and taxes within the period notified by the Service Provider, failing which the Service Provider may suspend clearing and the cargo shall remain at the Client's risk and cost.`, + ), +]; + +/** Build the stored `_CUSTOMS` / `_ETHIOPIAN_CUSTOMS` / `_NO_CUSTOMS` trio for one base pack. */ function splitByCustoms( base: ContractTemplateBase, codeStem: string, @@ -896,6 +925,14 @@ function splitByCustoms( description: `${base.description} Customs clearing is performed by the Service Provider.`, articles: [...base.articles, ...CUSTOMS_ARTICLES], }, + { + ...base, + code: `${codeStem}_ETHIOPIAN_CUSTOMS` as ContractTemplateCode, + name: `${base.name} (Ethiopian customs clearing only)`, + description: `${base.description} Only Ethiopian customs clearing is performed by the Service Provider; Djibouti clearing is handled by the Client.`, + documentTitle: `${base.documentTitle} (Ethiopian Customs Clearing Only)`, + articles: [...base.articles, ...ETHIOPIAN_CUSTOMS_ARTICLES], + }, { ...base, code: `${codeStem}_NO_CUSTOMS` as ContractTemplateCode, @@ -907,7 +944,8 @@ function splitByCustoms( } /** - * Ten templates: import and export each split by customs clearing, intercity + * Fourteen templates: import and export each split by customs clearing option + * (full, Ethiopian-only, none), intercity * not split at all — it is a domestic Ethiopian movement that crosses no * border, so there is no customs leg to contract for. */ diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index d0d437fb1..ac2507aa5 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1014,6 +1014,14 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:trains:change_wagon_yard", "Change yard of a coupled wagon", ), + // Supervisor-only: NOT part of FLEET_GRANULAR_KEYS — detach requests are + // filed under trains:assign_wagons, but deciding them is a separate grant so + // the requester and approver are different people. + perm( + "e1c00001-0001-4000-8000-000000000011", + "edr_freight_app:trains:approve_wagon_detach", + "Approve wagon detach/maintenance requests", + ), perm( "e1d00001-0001-4000-8000-000000000001", "edr_freight_app:routes:view", @@ -1485,6 +1493,30 @@ export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:train_scheduling:unload", "Confirm cargo unloaded (import, export, intercity)", ), + // Per-station loading/unloading time windows: the four buttons are separate + // permissions so start and end can be granted to different people. Booking + // load/unload additionally requires the matching window to have been started + // at that yard. + perm( + "a2a00001-0001-4000-8000-000000000008", + "edr_freight_app:train_scheduling:loading_start", + "Start a station's loading window", + ), + perm( + "a2a00001-0001-4000-8000-000000000009", + "edr_freight_app:train_scheduling:loading_end", + "End a station's loading window", + ), + perm( + "a2a00001-0001-4000-8000-000000000010", + "edr_freight_app:train_scheduling:unloading_start", + "Start a station's unloading window", + ), + perm( + "a2a00001-0001-4000-8000-000000000011", + "edr_freight_app:train_scheduling:unloading_end", + "End a station's unloading window", + ), ]; // L. Administration & settings (split from the coarse admin umbrella) @@ -2021,6 +2053,11 @@ export const FREIGHT_PERMS = { */ load: "edr_freight_app:train_scheduling:load", unload: "edr_freight_app:train_scheduling:unload", + // Per-station loading/unloading time-window buttons (start/end pairs). + loadingStart: "edr_freight_app:train_scheduling:loading_start", + loadingEnd: "edr_freight_app:train_scheduling:loading_end", + unloadingStart: "edr_freight_app:train_scheduling:unloading_start", + unloadingEnd: "edr_freight_app:train_scheduling:unloading_end", dispatch: "edr_freight_app:train_scheduling:dispatch", markPaid: "edr_freight_app:train_scheduling:mark_paid", expireBooking: "edr_freight_app:train_scheduling:expire_booking", @@ -2187,6 +2224,8 @@ export const FREIGHT_PERMS = { changeWagonYard: "edr_freight_app:trains:change_wagon_yard", toggleActive: "edr_freight_app:trains:toggle_active", disband: "edr_freight_app:trains:disband", + /** Decide detach/maintenance requests on a SCHEDULED train (4-eyes gate). */ + approveWagonDetach: "edr_freight_app:trains:approve_wagon_detach", }, routes: { view: "edr_freight_app:routes:view", @@ -2629,6 +2668,10 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.trainScheduling.update, FREIGHT_PERMS.trainScheduling.load, FREIGHT_PERMS.trainScheduling.unload, + FREIGHT_PERMS.trainScheduling.loadingStart, + FREIGHT_PERMS.trainScheduling.loadingEnd, + FREIGHT_PERMS.trainScheduling.unloadingStart, + FREIGHT_PERMS.trainScheduling.unloadingEnd, FREIGHT_PERMS.trainScheduling.cancel, FREIGHT_PERMS.trainScheduling.reschedule, FREIGHT_PERMS.trainScheduling.rulesManage, @@ -2851,6 +2894,10 @@ export const POSITION_PERMISSION_PRESETS = { FREIGHT_PERMS.trainScheduling.update, FREIGHT_PERMS.trainScheduling.load, FREIGHT_PERMS.trainScheduling.unload, + FREIGHT_PERMS.trainScheduling.loadingStart, + FREIGHT_PERMS.trainScheduling.loadingEnd, + FREIGHT_PERMS.trainScheduling.unloadingStart, + FREIGHT_PERMS.trainScheduling.unloadingEnd, FREIGHT_PERMS.trainScheduling.cancel, FREIGHT_PERMS.trainScheduling.reschedule, FREIGHT_PERMS.trainScheduling.rulesManage, diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx index 67851ab30..641541634 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx @@ -128,14 +128,27 @@ export function BookingRouteServiceCard({ background: "#F8FAFC", }} > - - - - Customs clearing agent:{" "} - - {booking.customsClearingAgent} + + + + + Customs clearing agent:{" "} + + {booking.customsClearingAgent} + - + {(booking.customsClearingAgentEmail || + booking.customsClearingAgentPhone) && ( + + {[ + booking.customsClearingAgentEmail, + booking.customsClearingAgentPhone, + ] + .filter(Boolean) + .join(" · ")} + + )} + ) : null} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 0ca021937..ff59b1b65 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -130,6 +130,7 @@ interface LineErrors { interface BulkErrors { quantity?: string; + wagons?: string; hazardous?: string; reefer?: string; } @@ -175,6 +176,8 @@ interface ContainerLineDraft { interface BulkDraft { cargoWeightTons: string; itemCount: string; + /** NUMBER_OF_WAGONS cargo only: wagons this shipment needs. */ + requestedWagons: string; hazardousQuantity: string; reeferQuantity: string; } @@ -203,7 +206,19 @@ function emptyLine(size: string): ContainerLineDraft { function bulkUnitOfMeasure( contract: Freight.IContract, -): "PER_TON" | "PER_ITEM" { +): "PER_TON" | "PER_ITEM" | "NUMBER_OF_WAGONS" { + // The cargo type's own configured unit wins; the pricing-line sniff below is + // the legacy fallback for contracts loaded without the cargoScope relation. + const configured = contract.cargoScope?.find( + (scope) => scope.cargoType?.unitOfMeasure, + )?.cargoType?.unitOfMeasure; + if ( + configured === "PER_TON" || + configured === "PER_ITEM" || + configured === "NUMBER_OF_WAGONS" + ) { + return configured; + } const hasPerItem = contract.pricingBreakdown?.lineItems?.some( (li) => li.unit === "per_item", ); @@ -322,6 +337,7 @@ export default function GlCreateBookingForm() { const [bulk, setBulk] = useState({ cargoWeightTons: "", itemCount: "", + requestedWagons: "", hazardousQuantity: "0", reeferQuantity: "0", }); @@ -521,16 +537,18 @@ export default function GlCreateBookingForm() { })), ); } else if (lines.bulk) { - setBulk({ + setBulk((b) => ({ cargoWeightTons: - lines.bulk.cargoWeightTons != null - ? String(lines.bulk.cargoWeightTons) + lines.bulk!.cargoWeightTons != null + ? String(lines.bulk!.cargoWeightTons) : "", itemCount: - lines.bulk.itemCount != null ? String(lines.bulk.itemCount) : "", - hazardousQuantity: String(lines.bulk.hazardousQuantity ?? 0), + lines.bulk!.itemCount != null ? String(lines.bulk!.itemCount) : "", + // The request never carries a wagon count — GL enters it here. + requestedWagons: b.requestedWagons, + hazardousQuantity: String(lines.bulk!.hazardousQuantity ?? 0), reeferQuantity: "0", - }); + })); } if (bookingRequest.contractRouteId) setContractRouteId(bookingRequest.contractRouteId); @@ -618,6 +636,7 @@ export default function GlCreateBookingForm() { returnQuantity: Number(l.returnQuantity || 0), })), bulkQuantity: Number(bulk.cargoWeightTons || bulk.itemCount || 0), + bulkRequestedWagons: Number(bulk.requestedWagons || 0), bulkHazardousQuantity: Number(bulk.hazardousQuantity || 0), bulkReeferQuantity: Number(bulk.reeferQuantity || 0), }), @@ -979,6 +998,12 @@ export default function GlCreateBookingForm() { if (Number.isNaN(qty) || qty <= 0) { errs.quantity = "Enter a quantity greater than 0."; } + if (bulkUom === "NUMBER_OF_WAGONS") { + const wagons = Number(bulk.requestedWagons || 0); + if (!Number.isInteger(wagons) || wagons < 1) { + errs.wagons = "Enter the number of wagons needed (at least 1)."; + } + } const h = Number(bulk.hazardousQuantity || 0); if (Number.isNaN(h) || h < 0) { errs.hazardous = "Enter a valid hazardous quantity."; @@ -1017,7 +1042,10 @@ export default function GlCreateBookingForm() { line.every((e) => !e.containerNumber && !e.vgmTons), ) && !cargoDescriptionError - : !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer; + : !bulkErrors.quantity && + !bulkErrors.wagons && + !bulkErrors.hazardous && + !bulkErrors.reefer; // COMPLETION never blocks on an odd 20ft total: a customs instance can share // the wagon via the manual pair (consolidationActive), and anything else is @@ -1152,6 +1180,9 @@ export default function GlCreateBookingForm() { reeferQuantity: Number(bulk.reeferQuantity || 0) || undefined, }, ]; + if (bulkUom === "NUMBER_OF_WAGONS" && bulk.requestedWagons !== "") { + payload.requestedWagons = Number(bulk.requestedWagons); + } } return payload; @@ -2049,6 +2080,27 @@ export default function GlCreateBookingForm() { radius={10} styles={fieldStyles} /> + {bulkUom === "NUMBER_OF_WAGONS" && ( + + setBulk((b) => ({ + ...b, + requestedWagons: e.currentTarget.value, + })) + } + radius={10} + styles={fieldStyles} + /> + )} {contract.isHazardous && ( ; /** Bulk: tons (or item count) + hazardous/reefer qty. */ bulkQuantity: number; + /** NUMBER_OF_WAGONS cargo: the wagon count GL enters (0 otherwise). */ + bulkRequestedWagons: number; bulkHazardousQuantity: number; bulkReeferQuantity: number; } @@ -132,7 +134,6 @@ export function computeGlShipmentTotal( } } } else { - const qty = q.bulkQuantity; const rate = rateFor( (i) => @@ -140,6 +141,9 @@ export function computeGlShipmentTotal( !i.isClearance && !i.conditionalOn, ) ?? items[0]; + // NUMBER_OF_WAGONS cargo: a per-wagon base rate bills the requested count. + const qty = + rate?.unit === "per_wagon" ? q.bulkRequestedWagons : q.bulkQuantity; if (rate && qty > 0) { lines.push({ label: rate.label, @@ -179,8 +183,14 @@ export function computeGlShipmentTotal( // it (the commodity needs lashing). Per-ton scales by tonnage; per-wagon // depends on the wagon capacity the train stocks — shown at real pricing. const lashing = items.find((i) => i.conditionalOn === "has_lashing"); - if (lashing && (lashing.unit === "per_ton" || lashing.unit === "per_item")) { - const tons = q.bulkQuantity; + if ( + lashing && + (lashing.unit === "per_ton" || + lashing.unit === "per_item" || + (lashing.unit === "per_wagon" && q.bulkRequestedWagons > 0)) + ) { + const tons = + lashing.unit === "per_wagon" ? q.bulkRequestedWagons : q.bulkQuantity; if (tons > 0) { lines.push({ label: lashing.label, @@ -208,6 +218,8 @@ export function computeGlShipmentTotal( : boxes; } else if (cl.unit === "per_ton" || cl.unit === "per_item") { qty = q.bulkQuantity; + } else if (cl.unit === "per_wagon") { + qty = q.bulkRequestedWagons; } else if (cl.unit === "flat") { qty = 1; } diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/DetachedWagonsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/DetachedWagonsPanel.tsx index 72428c329..a05dc51b3 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/DetachedWagonsPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/DetachedWagonsPanel.tsx @@ -5,18 +5,30 @@ import { Group, Pagination, Paper, + Select, Stack, Table, Text, ThemeIcon, Tooltip, } from "@mantine/core"; -import { useQuery } from "@tanstack/react-query"; -import { Link2, MapPin, PackageOpen, User } from "lucide-react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { isAxiosError } from "axios"; +import { ArrowRightLeft, Link2, MapPin, PackageOpen, User } from "lucide-react"; import { useState } from "react"; +import { useToast } from "@/hooks/use-toast"; import { api } from "@/services/api"; +const parseError = (error: unknown, fallback: string) => { + if (isAxiosError(error)) { + const message = error.response?.data?.message; + if (Array.isArray(message)) return message.join(", "); + if (typeof message === "string") return message; + } + return fallback; +}; + interface Props { trainId: string; /** Staff may attach and the train is editable (not out on a run). */ @@ -50,6 +62,47 @@ export default function DetachedWagonsPanel({ // Selection is page-scoped in the header checkbox but survives paging, so // staff can gather wagons across pages into one attach. const [selected, setSelected] = useState>(new Set()); + + // Attach the selection to a DIFFERENT built train: pick a target, reuse the + // same assign endpoint with that train's id. The builder attach is + // yard-agnostic, so any loose AVAILABLE wagon qualifies; a train that is + // out on a run rejects server-side and is disabled here too. + const { toast } = useToast(); + const [targetTrainId, setTargetTrainId] = useState(null); + const trainsQuery = useQuery( + api.trainBuilder.list.queryOptions({ + input: { filters: { pageSize: 200, sortBy: "code", sortOrder: "ASC" } }, + enabled: canAttach, + staleTime: 60_000, + }), + ); + const trainOptions = (trainsQuery.data?.items ?? []) + .filter((t) => t.id !== trainId) + .map((t) => ({ + value: t.id, + label: `${t.code}${t.trainName ? ` · ${t.trainName}` : ""} — ${t.wagonCount} wagon${t.wagonCount === 1 ? "" : "s"}${t.status === "IN_SERVICE" ? " (in service)" : ""}`, + disabled: t.status === "IN_SERVICE", + })); + const attachOther = useMutation(api.trainBuilder.assignWagons.mutationOptions()); + const handleAttachOther = async () => { + if (!targetTrainId || !selected.size) return; + const target = trainsQuery.data?.items.find((t) => t.id === targetTrainId); + try { + await attachOther.mutateAsync({ id: targetTrainId, wagonIds: [...selected] }); + toast({ + title: `${selected.size} wagon(s) attached to ${target?.code ?? "the selected train"}`, + }); + setSelected(new Set()); + setTargetTrainId(null); + void query.refetch(); + } catch (error) { + toast({ + title: "Could not attach to the other train", + description: parseError(error, "The target train may be out on a run."), + variant: "destructive", + }); + } + }; const allSelected = rows.length > 0 && rows.every((r) => selected.has(r.wagonId)); const toggle = (wagonId: string, checked: boolean) => @@ -79,17 +132,40 @@ export default function DetachedWagonsPanel({ {canAttach ? ( - + + +
${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))}
@@ -389,16 +478,18 @@ export function LogPassYardWorkModal({ ? "You don't have permission to load cargo" : !logged ? "Log the pass first — the train must be at this yard" - : !row.canLoad - ? "Booking is not ready to load (payment pending)" - : "Confirm cargo loaded onto the train" + : !loadingStarted + ? "Start loading first" + : !row.canLoad + ? "Booking is not ready to load (payment pending)" + : "Confirm cargo loaded onto the train" } > {!logged ? ( - + + ) : null} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleStatusBadge.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleStatusBadge.tsx index 565187ac2..186588dd1 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleStatusBadge.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleStatusBadge.tsx @@ -35,6 +35,8 @@ export function SchedulingStatusBadge({ status }: { status?: string | null }) { ELIGIBLE: "blue", SCHEDULED: "indigo", DISPATCHED: "edr-green", + WAITING_FOR_WAGON: "yellow", + MANUAL_ONLY: "orange", }; return ( diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx index db84ec373..788265ae2 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx @@ -799,6 +799,17 @@ export function ScheduleWorkspacePanel({ {group.rows.map((b) => { const ref = b.reference ?? b.id.slice(0, 8); const journey = journeyById.get(b.id); + // Server gates load/unload on the yard's started work + // window (Start loading/unloading buttons) — mirror it. + const loadWindowStarted = Boolean( + b.originYardId && + schedule.stationWorkLogs?.[b.originYardId]?.loading?.startedAt, + ); + const unloadWindowStarted = Boolean( + b.destinationYardId && + schedule.stationWorkLogs?.[b.destinationYardId]?.unloading + ?.startedAt, + ); const riding = b.status === "IN_TRANSIT"; const done = ["ARRIVED", "COMPLETED", "DELIVERED"].includes( b.status ?? "", @@ -845,7 +856,9 @@ export function ScheduleWorkspacePanel({ label={ !canLoad ? "You don't have permission to load cargo" - : boardHere + : boardHere && !loadWindowStarted + ? `Start loading at ${group.label} first` + : boardHere ? `Load cargo onto the train at ${group.label}` : passed ? `Train already passed ${group.label} — this cargo missed its stop` @@ -860,7 +873,7 @@ export function ScheduleWorkspacePanel({ variant="filled" color="edr-green" radius="md" - disabled={!boardHere || !canLoad} + disabled={!boardHere || !canLoad || !loadWindowStarted} leftSection={} loading={ loadJourney.isPending && @@ -877,9 +890,11 @@ export function ScheduleWorkspacePanel({ {showTruckToTrain ? ( @@ -888,7 +903,7 @@ export function ScheduleWorkspacePanel({ variant="light" color="blue" radius="md" - disabled={!canLoad} + disabled={!canLoad || !loadWindowStarted} leftSection={} loading={truckToTrainPending === b.id} onClick={() => @@ -908,9 +923,11 @@ export function ScheduleWorkspacePanel({ label={ !canUnload ? "You don't have permission to unload cargo" - : alightHere - ? "Unload at this yard — stamps the booking's arrival" - : "Unloads when the train reaches its destination yard" + : alightHere && !unloadWindowStarted + ? "Start unloading at this yard first" + : alightHere + ? "Unload at this yard — stamps the booking's arrival" + : "Unloads when the train reaches its destination yard" } withArrow > @@ -919,7 +936,7 @@ export function ScheduleWorkspacePanel({ variant="light" color="orange" radius="md" - disabled={!alightHere || !canUnload} + disabled={!alightHere || !canUnload || !unloadWindowStarted} leftSection={} loading={ unloadJourney.isPending && diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/StationWorkControls.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/StationWorkControls.tsx new file mode 100644 index 000000000..96b524551 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/StationWorkControls.tsx @@ -0,0 +1,280 @@ +import { + ActionIcon, + Badge, + Button, + Group, + Popover, + Stack, + Text, + Tooltip, +} from "@mantine/core"; +import { DateTimePicker } from "@mantine/dates"; +import { useMutation } from "@tanstack/react-query"; +import { Pencil, PlayCircle, StopCircle } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; +import { useToast } from "@/hooks/use-toast"; +import { api } from "@/services/api"; +import type { StationWorkPhaseLog } from "@/types/trainScheduling"; + +const parseError = (error: unknown, fallback: string) => { + const message = (error as { response?: { data?: { message?: string | string[] } } }) + ?.response?.data?.message; + if (Array.isArray(message)) return message.join("; "); + return message || (error as Error)?.message || fallback; +}; + +const fmtTime = (iso: string) => { + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? iso : d.toLocaleString(); +}; + +const fmtElapsed = (fromIso: string, toIso?: string | null) => { + const from = new Date(fromIso).getTime(); + const to = toIso ? new Date(toIso).getTime() : Date.now(); + const mins = Math.max(0, Math.round((to - from) / 60_000)); + const h = Math.floor(mins / 60); + const m = mins % 60; + return h > 0 ? `${h}h ${m}m` : `${m}m`; +}; + +/** Pencil-popover to correct an already-recorded start/end timestamp. */ +function EditTimeButton({ + label, + value, + disabled, + disabledReason, + minDate, + maxDate, + onSave, + saving, +}: { + label: string; + value: string; + disabled: boolean; + disabledReason: string; + minDate?: Date; + maxDate?: Date; + onSave: (at: Date) => void; + saving: boolean; +}) { + const [opened, setOpened] = useState(false); + const [draft, setDraft] = useState(null); + useEffect(() => { + if (opened) setDraft(new Date(value)); + }, [opened, value]); + + return ( + + + + setOpened((o) => !o)} + > + + + + + + + setDraft(v ? new Date(v) : null)} + minDate={minDate} + maxDate={maxDate ?? new Date()} + valueFormat="DD MMM YYYY HH:mm" + clearable={false} + radius="md" + maw={280} + /> + + + + + + + + ); +} + +/** + * Start/End buttons + elapsed time for one station's loading OR unloading + * window. Booking load/unload at the yard is server-gated on the window having + * been started, so these buttons come first in the operator's flow. Each of + * the four buttons (start/end × loading/unloading) is its own permission, and + * the pencil edits a recorded time under the same permission that set it. + */ +export function StationWorkControls({ + scheduleId, + yardId, + phase, + log, +}: { + scheduleId: string; + yardId: string; + phase: "loading" | "unloading"; + log?: StationWorkPhaseLog | null; +}) { + const { user } = useAuth(); + const { toast } = useToast(); + const canStart = hasPermission( + user, + phase === "loading" + ? FREIGHT_PERMS.trainScheduling.loadingStart + : FREIGHT_PERMS.trainScheduling.unloadingStart, + ); + const canEnd = hasPermission( + user, + phase === "loading" + ? FREIGHT_PERMS.trainScheduling.loadingEnd + : FREIGHT_PERMS.trainScheduling.unloadingEnd, + ); + + const record = useMutation(api.trainScheduling.recordStationWork.mutationOptions()); + + // Re-render each minute so the running elapsed time ticks while unended. + const [, setTick] = useState(0); + useEffect(() => { + if (!log?.startedAt || log?.endedAt) return; + const t = setInterval(() => setTick((n) => n + 1), 60_000); + return () => clearInterval(t); + }, [log?.startedAt, log?.endedAt]); + + const doRecord = (edge: "start" | "end", at?: Date) => { + record.mutate( + { scheduleId, yardId, phase, edge, ...(at ? { at: at.toISOString() } : {}) }, + { + onSuccess: () => + toast({ + title: `${phase === "loading" ? "Loading" : "Unloading"} ${edge} recorded`, + }), + onError: (err) => + toast({ + title: `Could not record ${phase} ${edge}`, + description: parseError(err, "Please try again"), + variant: "destructive", + }), + }, + ); + }; + + const title = phase === "loading" ? "Loading" : "Unloading"; + const started = Boolean(log?.startedAt); + const ended = Boolean(log?.endedAt); + + return ( + + + {title} + {ended ? " done" : started ? " in progress" : " not started"} + + + {!started ? ( + + + + ) : ( + <> + + + {fmtTime(log!.startedAt!)} → {ended ? fmtTime(log!.endedAt!) : "…"} ( + {fmtElapsed(log!.startedAt!, log?.endedAt)}) + + {log?.startedByName || log?.endedByName ? ( + + + {log?.endedByName ?? log?.startedByName} + + + ) : null} + doRecord("start", at)} + saving={record.isPending} + /> + {ended ? ( + doRecord("end", at)} + saving={record.isPending} + /> + ) : null} + + {!ended ? ( + + + + ) : null} + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 42cebe0b9..8249537a5 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -481,6 +481,12 @@ export const URL_CONSTANTS = { FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`, DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`, YARD_WORK: (id: string) => `/train-scheduling/schedules/${id}/yard-work`, + STATION_WORK: ( + id: string, + yardId: string, + phase: "loading" | "unloading", + edge: "start" | "end", + ) => `/train-scheduling/schedules/${id}/stations/${yardId}/${phase}/${edge}`, BOOKING_LOAD: (id: string, bookingId: string) => `/train-scheduling/schedules/${id}/bookings/${bookingId}/load`, BOOKING_UNLOAD: (id: string, bookingId: string) => diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index d8ef47f35..b3d721eb5 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -107,6 +107,11 @@ export const FREIGHT_PERMS = { /** Confirm cargo loaded/unloaded at a yard — import, export, and intercity alike. */ load: "edr_freight_app:train_scheduling:load", unload: "edr_freight_app:train_scheduling:unload", + /** Per-station loading/unloading time-window buttons (start/end pairs). */ + loadingStart: "edr_freight_app:train_scheduling:loading_start", + loadingEnd: "edr_freight_app:train_scheduling:loading_end", + unloadingStart: "edr_freight_app:train_scheduling:unloading_start", + unloadingEnd: "edr_freight_app:train_scheduling:unloading_end", cancel: "edr_freight_app:train_scheduling:cancel", reschedule: "edr_freight_app:train_scheduling:reschedule", rulesManage: "edr_freight_app:train_scheduling:rules_manage", @@ -225,6 +230,8 @@ export const FREIGHT_PERMS = { changeWagonYard: "edr_freight_app:trains:change_wagon_yard", toggleActive: "edr_freight_app:trains:toggle_active", disband: "edr_freight_app:trains:disband", + /** Decide detach/maintenance requests on a SCHEDULED train (4-eyes gate). */ + approveWagonDetach: "edr_freight_app:trains:approve_wagon_detach", }, routes: { view: "edr_freight_app:routes:view", diff --git a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx index 1adcd2e6c..d8e360114 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx @@ -8,6 +8,7 @@ import { Card, Group, Modal, + Radio, SegmentedControl, Select, SimpleGrid, @@ -23,10 +24,13 @@ import { Container, Eye, FileText, + Flag, + Globe, Lock, Pencil, Plus, Trash2, + type LucideIcon, } from "lucide-react"; import { useAuth } from "@/auth/useAuth"; @@ -62,15 +66,45 @@ function isBulk(template: ContractTemplate): boolean { // System container codes are DIRECTION_CONTAINER(_CUSTOMS); intercity is // domestic and crosses no border, so it has no customs variant at all — hence -// null rather than false, which would wrongly read as a deliberate "client -// clears its own customs" choice. -function customsVariant(template: ContractTemplate): boolean | null { - if (isBulk(template)) return template.withCustoms ?? null; - if (template.code.endsWith("_NO_CUSTOMS")) return false; - if (template.code.endsWith("_CUSTOMS")) return true; +// null rather than "WITHOUT", which would wrongly read as a deliberate "client +// clears its own customs" choice. "ETHIOPIAN" is the with-customs variant +// restricted to Ethiopian-side clearing (Djibouti stays with the client). +type CustomsVariant = "WITH" | "WITHOUT" | "ETHIOPIAN" | null; + +function customsVariant(template: ContractTemplate): CustomsVariant { + if (isBulk(template)) { + if (template.withCustoms == null) return null; + if (!template.withCustoms) return "WITHOUT"; + return template.ethiopianCustomsOnly ? "ETHIOPIAN" : "WITH"; + } + if (template.code.endsWith("_NO_CUSTOMS")) return "WITHOUT"; + if (template.code.endsWith("_ETHIOPIAN_CUSTOMS")) return "ETHIOPIAN"; + if (template.code.endsWith("_CUSTOMS")) return "WITH"; return null; } +const CUSTOMS_BADGE: Record< + Exclude, + { label: string; color: string; tooltip: string } +> = { + WITH: { + label: "With customs", + color: "teal", + tooltip: "Used when the contract has customs clearing enabled", + }, + ETHIOPIAN: { + label: "Ethiopian customs", + color: "indigo", + tooltip: + "Used when the service type includes Ethiopian customs clearing only — Djibouti clearing stays with the client", + }, + WITHOUT: { + label: "No customs", + color: "gray", + tooltip: "Used when the client handles its own customs clearing", + }, +}; + // Bulk templates carry the direction on the row; the fixed container codes // carry it as the code prefix. function directionOf(template: ContractTemplate): string { @@ -79,6 +113,39 @@ function directionOf(template: ContractTemplate): string { : template.code.split("_")[0]; } +/** The three customs options offered in the create-template modal. */ +const CUSTOMS_OPTIONS: Array<{ + value: string; + label: string; + description: string; + color: string; + icon: LucideIcon; +}> = [ + { + value: "true", + label: "With customs clearing", + description: + "The Service Provider clears customs in both Djibouti and Ethiopia on the client's behalf.", + color: "teal", + icon: Globe, + }, + { + value: "ethiopian", + label: "Ethiopian customs only", + description: + "The Service Provider clears the Ethiopian side only — Djibouti clearing stays with the client. Used for service types marked “Ethiopian customs only”.", + color: "indigo", + icon: Flag, + }, + { + value: "false", + label: "Without customs clearing", + description: "Transport only — the client handles its own declarations.", + color: "gray", + icon: FileText, + }, +]; + function formatUpdated(value: string): string { return new Date(value).toLocaleDateString("en-GB", { day: "numeric", @@ -116,7 +183,7 @@ export default function ContractTemplatesPage() { - + + + New bulk contract template + + + One template per trade direction, customs option and commodity + + + } + > +
Trade direction @@ -269,28 +353,6 @@ function CreateTemplateModal({ />
- {intercity ? ( - - Intercity contracts are domestic and cross no border, so they have - no customs clearing variant — one template per cargo type. - - ) : ( -
- - Customs clearing - - -
- )} -