feat: add wagon usage computation and maintenance logging features

- Implemented  utility to calculate wagon usage metrics for train schedules.
- Created  for sending wagons to maintenance with optional notes.
- Added unit tests for train builder maintenance functionalities, including formatting train run labels and building maintenance notes.
- Developed  component for merging train schedules with detailed previews and reasons for merging.
- Introduced  component for selecting wagons with search functionality and selection limits.
- Created  for displaying and filtering audit logs, including detailed views of individual log entries.
- Added  for handling API interactions related to audit logs, including fetching logs and entity types.
This commit is contained in:
marshalyordanos
2026-08-12 09:36:50 +03:00
parent 35e5404b41
commit 5da36eb128
77 changed files with 6275 additions and 296 deletions

View File

@@ -0,0 +1,639 @@
/**
* Freight API — every state-changing endpoint (POST / PUT / PATCH / DELETE).
*
* Shape: "<METHOD> <path>": [title, method, entity]
*
* Keyed by method + path rather than path alone: 50 paths serve more than one
* method (PATCH and DELETE on /api/contracts/:id, for example), so a path-only
* key would collide and drop those endpoints.
*
* Paths include the global prefix `api` (see app.setGlobalPrefix in src/main.ts).
* Titles come from each route's @ApiOperation summary, falling back to a
* humanized handler name where a route has none.
*
* Excludes the AI Assist and Account entities.
* Generated from the controllers under src/ — 488 endpoints.
*/
const AUDIT_ENDPOINTS = {
// Approval Rule
"POST /api/approval-rules": ["Create an approval rule step", "POST", "Approval Rule"],
"PATCH /api/approval-rules/:id": ["Update an approval rule", "PATCH", "Approval Rule"],
"DELETE /api/approval-rules/:id": ["Soft-delete an approval rule", "DELETE", "Approval Rule"],
"POST /api/approval-rules/:id/move-order": ["Move an approval step up or down within its chain", "POST", "Approval Rule"],
"POST /api/approval-rules/reorder": ["Bulk reorder approval steps within a chain", "POST", "Approval Rule"],
// Booking
"POST /api/bookings": ["Create a new freight booking (DRAFT)", "POST", "Booking"],
"POST /api/bookings/:bookingId/allocate-containers": ["Allocate containers to vehicles", "POST", "Booking"],
"PATCH /api/bookings/:id": ["Update booking", "PATCH", "Booking"],
"DELETE /api/bookings/:id": ["Soft-delete DRAFT booking", "DELETE", "Booking"],
"POST /api/bookings/:id/cancel": ["Cancel booking", "POST", "Booking"],
"POST /api/bookings/:id/cancel-hold": ["Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED);", "POST", "Booking"],
"POST /api/bookings/:id/clearance/declaration": ["GL ET uploads customs declaration on booking (GENERAL customs)", "POST", "Booking"],
"POST /api/bookings/:id/clearance/delivery-order": ["Upload Booking Delivery Order", "POST", "Booking"],
"POST /api/bookings/:id/clearance/documents": ["Customer uploads clearance documents (fieldname = document key)", "POST", "Booking"],
"POST /api/bookings/:id/clearance/draft-declaration": ["GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review", "POST", "Booking"],
"POST /api/bookings/:id/clearance/draft-declaration/accept": ["Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia", "POST", "Booking"],
"POST /api/bookings/:id/clearance/draft-declaration/change": ["Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)", "POST", "Booking"],
"POST /api/bookings/:id/clearance/duty": ["GL ET sets duty/tax on booking with notice attachment", "POST", "Booking"],
"POST /api/bookings/:id/clearance/duty-slip": ["Customer uploads duty/tax payment slip on booking", "POST", "Booking"],
"POST /api/bookings/:id/clearance/export-release": ["Confirm Booking Export Release", "POST", "Booking"],
"POST /api/bookings/:id/clearance/finalize": ["GL finalizes clearance (requires 100% approved) → CLEARANCE_READY", "POST", "Booking"],
"POST /api/bookings/:id/clearance/finalize-pre-clearance": ["GL ET finalizes import pre-clearance on booking", "POST", "Booking"],
"POST /api/bookings/:id/clearance/output-documents": ["GL uploads customs output documents (IM4/EX3/…)", "POST", "Booking"],
"POST /api/bookings/:id/clearance/proceed": ["Customer requests operation with a schedule day", "POST", "Booking"],
"POST /api/bookings/:id/clearance/release-order": ["Upload Booking Release Order", "POST", "Booking"],
"POST /api/bookings/:id/clearance/review": ["GL reviews a clearance document (Approve | Query)", "POST", "Booking"],
"POST /api/bookings/:id/clearance/ro-amendment": ["Request Booking RO Amendment", "POST", "Booking"],
"POST /api/bookings/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Booking"],
"POST /api/bookings/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration", "POST", "Booking"],
"POST /api/bookings/:id/clearance/transit-permit": ["Upload Booking Transit Permit", "POST", "Booking"],
"POST /api/bookings/:id/confirm-submit": ["Confirm submit after price change", "POST", "Booking"],
"POST /api/bookings/:id/consolidation": ["Request freight consolidation", "POST", "Booking"],
"DELETE /api/bookings/:id/consolidation": ["Remove consolidation pairing", "DELETE", "Booking"],
"POST /api/bookings/:id/contract/generate": ["Generate contract PDF from template", "POST", "Booking"],
"POST /api/bookings/:id/contract/sign": ["Apply digital signature (customer or staff)", "POST", "Booking"],
"POST /api/bookings/:id/customer-cancel": ["Customer cancels their own booking before payment — no cancellation fee", "POST", "Booking"],
"POST /api/bookings/:id/customer-truck-assignment": ["Customer assigns external truck and driver for terminal pickup", "POST", "Booking"],
"POST /api/bookings/:id/customer-trucks": ["Add a customer self-haul truck carrying 12 of the booking containers", "POST", "Booking"],
"PATCH /api/bookings/:id/customer-trucks/:assignmentId": ["Edit a not-yet-arrived customer truck (plate/driver/type + containers)", "PATCH", "Booking"],
"DELETE /api/bookings/:id/customer-trucks/:assignmentId": ["Remove a not-yet-arrived customer truck from a booking", "DELETE", "Booking"],
"POST /api/bookings/:id/customer-trucks/:assignmentId/depart": ["Register an import truck leaving: containers loaded + weighed gross (staff)", "POST", "Booking"],
"POST /api/bookings/:id/customer-trucks/:assignmentId/load": ["Truck_dispatch: load selected containers onto a truck (staff)", "POST", "Booking"],
"POST /api/bookings/:id/customer-trucks/bulk": ["Bulk add customer trucks from array payload (Excel parsed)", "POST", "Booking"],
"POST /api/bookings/:id/customer/sign": ["Customer digital signature (deprecated — use POST contract/sign)", "POST", "Booking"],
"POST /api/bookings/:id/documents": ["Upload documents for a booking (DRAFT only)", "POST", "Booking"],
"PATCH /api/bookings/:id/export-handover-mode": ["Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first", "PATCH", "Booking"],
"POST /api/bookings/:id/generate-grn": ["Generate a GRN over the received containers (all received, or a subset) — one GRN per batch", "POST", "Booking"],
"POST /api/bookings/:id/generate-price": ["Generate price preview (DRAFT or CHANGES_REQUESTED)", "POST", "Booking"],
"POST /api/bookings/:id/government-expedite": ["Expedite government booking to PAID / ELIGIBLE for scheduling", "POST", "Booking"],
"POST /api/bookings/:id/marketing/approve": ["Staff contract signature and fully execute (use contract/sign STAFF preferred)", "POST", "Booking"],
"POST /api/bookings/:id/operation/review": ["Operations reviews an operation request: ACCEPT (→ batch pool),", "POST", "Booking"],
"POST /api/bookings/:id/operations/complete": ["Mark completed", "POST", "Booking"],
"POST /api/bookings/:id/operations/start-transit": ["Mark in transit", "POST", "Booking"],
"POST /api/bookings/:id/reject": ["Customer reject price estimate", "POST", "Booking"],
"POST /api/bookings/:id/staff/accept": ["Staff accept intake → set contract validity window + start approval chain", "POST", "Booking"],
"POST /api/bookings/:id/staff/reject": ["Staff final reject", "POST", "Booking"],
"POST /api/bookings/:id/staff/request-changes": ["Staff return booking for customer updates", "POST", "Booking"],
"POST /api/bookings/:id/submit": ["Customer submit booking", "POST", "Booking"],
"POST /api/bookings/:id/wagon-cancellations": ["Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles", "POST", "Booking"],
"POST /api/bookings/:id/wagon-cancellations/preview": ["Preview the fee/credit of a partial wagon cancellation (no writes)", "POST", "Booking"],
"POST /api/bookings/wagon-cancellations/:cancellationId/rebook": ["Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)", "POST", "Booking"],
"POST /api/bookings/wagon-cancellations/:cancellationId/withdraw": ["Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)", "POST", "Booking"],
// Cargo
"POST /api/cargoes": ["Create a new cargo", "POST", "Cargo"],
"PATCH /api/cargoes/:id": ["Update a cargo", "PATCH", "Cargo"],
"DELETE /api/cargoes/:id": ["Delete a cargo", "DELETE", "Cargo"],
"POST /api/cargoes/:id/deliver": ["Mark cargo as delivered", "POST", "Cargo"],
"POST /api/cargoes/:id/load": ["Load cargo into a container", "POST", "Cargo"],
"POST /api/cargoes/:id/unload": ["Unload cargo from container", "POST", "Cargo"],
// Cargo Type
"POST /api/cargo-types": ["Create a cargo type", "POST", "Cargo Type"],
"PATCH /api/cargo-types/:id": ["Update a cargo type", "PATCH", "Cargo Type"],
"DELETE /api/cargo-types/:id": ["Soft-delete a cargo type", "DELETE", "Cargo Type"],
"POST /api/cargo-types/:id/move-order": ["Move a cargo type up or down in display order", "POST", "Cargo Type"],
"POST /api/cargo-types/reorder": ["Bulk reorder cargo types by ID list", "POST", "Cargo Type"],
// Company
"POST /api/companies": ["Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)", "POST", "Company"],
"POST /api/companies/:companyId/documents": ["Upload documents for a company (onboarding)", "POST", "Company"],
"POST /api/companies/:companyId/profiles": ["Add a profile (employee) to a company", "POST", "Company"],
"PATCH /api/companies/:id": ["Update a company", "PATCH", "Company"],
"DELETE /api/companies/:id": ["Soft-delete a company", "DELETE", "Company"],
"POST /api/companies/change-requests/:id/approve": ["Approve a pending profile change request (applies the changes)", "POST", "Company"],
"POST /api/companies/change-requests/:id/reject": ["Reject a pending profile change request with a note", "POST", "Company"],
"POST /api/companies/change-requests/:id/request-changes": ["Ask for specific changes on a pending request without rejecting it (row stays open, next edit appends to it)", "POST", "Company"],
"POST /api/companies/company-profile": ["Create a single operational profile for the current user's company. The role starts pending and does not become the active mode", "POST", "Company"],
"POST /api/companies/company-profiles": ["Add operational profile(s) (importer/exporter/forwarder) to the current user's company", "POST", "Company"],
"POST /api/companies/company-profiles/:profileId/license": ["Add business-license document(s) to a profile. For an approved company", "POST", "Company"],
"DELETE /api/companies/company-profiles/:profileId/license/:fileId": ["Remove a business-license file (staged for review on an approved company)", "DELETE", "Company"],
"POST /api/companies/company-profiles/:profileId/license/:fileId/replace": ["Replace a business-license file with a newly uploaded one (staged for", "POST", "Company"],
"POST /api/companies/company-profiles/:profileId/reapply": ["Resubmit a rejected operational role for approval (→ pending)", "POST", "Company"],
"PATCH /api/companies/company-profiles/:profileId/status": ["Update a company profile's approval status", "PATCH", "Company"],
"POST /api/companies/create": ["Create a company with its associated external profile (onboarding)", "POST", "Company"],
"POST /api/companies/documents/:fileId/request-change": ["Ask the customer to correct one uploaded document", "POST", "Company"],
"POST /api/companies/fetch-etrade-info": ["Fetch company info from eTrade by TIN", "POST", "Company"],
"POST /api/companies/identity/fayda/complete": ["Bind a completed Fayda verification to the company's owner or Power of Attorney", "POST", "Company"],
"DELETE /api/companies/identity/fayda/poa": ["Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together", "DELETE", "Company"],
"DELETE /api/companies/identity/gm": ["Clear the General Manager's identity — the \\\"same as owner\\\" declaration or a verification, and the details either wrote", "DELETE", "Company"],
"POST /api/companies/identity/gm/same-as-owner": ["Declare the General Manager is the company's owner, copying the owner's verified identity across", "POST", "Company"],
"POST /api/companies/identity/poa/same-as-owner": ["Declare the Power of Attorney is the company's owner, copying the owner's identity across", "POST", "Company"],
"DELETE /api/companies/identity/poa/same-as-owner": ["Undo the Power of Attorney \\\"same as owner\\\" declaration and the identity it copied, leaving the representative open to be verified in their own right", "DELETE", "Company"],
"PATCH /api/companies/onboarding-step": ["Persist the user's current onboarding wizard step", "PATCH", "Company"],
"POST /api/companies/onboarding/complete": ["Mark the current user's onboarding as complete", "POST", "Company"],
"POST /api/companies/onboarding/start": ["Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally", "POST", "Company"],
"POST /api/companies/poa-delegation": ["Upload the Power of Attorney delegation letter, replacing any existing one", "POST", "Company"],
"DELETE /api/companies/poa-delegation/:fileId": ["Remove the Power of Attorney delegation letter (staged for review on an approved company)", "DELETE", "Company"],
"PATCH /api/companies/profile": ["Update profile (flattened settings page)", "PATCH", "Company"],
// Compliance
"POST /api/compliance": ["Create a compliance record", "POST", "Compliance"],
"PATCH /api/compliance/:id": ["Update a compliance record", "PATCH", "Compliance"],
"DELETE /api/compliance/:id": ["Soft-delete a compliance record", "DELETE", "Compliance"],
// Consignment
"POST /api/consignments": ["Create a new consignment", "POST", "Consignment"],
// Container
"POST /api/containers": ["Create a new container", "POST", "Container"],
"PATCH /api/containers/:id": ["Update a container", "PATCH", "Container"],
"DELETE /api/containers/:id": ["Delete a container", "DELETE", "Container"],
"POST /api/containers/:id/assign-wagon": ["Assign container to a wagon", "POST", "Container"],
"POST /api/containers/:id/unassign-wagon": ["Unassign container from wagon", "POST", "Container"],
// Container Type
"POST /api/container-types": ["Create a container type", "POST", "Container Type"],
"PATCH /api/container-types/:id": ["Update a container type", "PATCH", "Container Type"],
"DELETE /api/container-types/:id": ["Soft-delete a container type", "DELETE", "Container Type"],
"POST /api/container-types/:id/move-order": ["Move a container type up or down in display order", "POST", "Container Type"],
"POST /api/container-types/reorder": ["Bulk reorder container types by ID list", "POST", "Container Type"],
// Contract
"POST /api/contracts": ["Create a new contract (DRAFT) with routes + cargo scope", "POST", "Contract"],
"PATCH /api/contracts/:id": ["Update contract", "PATCH", "Contract"],
"DELETE /api/contracts/:id": ["Soft-delete DRAFT contract", "DELETE", "Contract"],
"POST /api/contracts/:id/approval-steps/:stepId/approve": ["Approve one approval step in sequence", "POST", "Contract"],
"POST /api/contracts/:id/approval-steps/:stepId/reject": ["Reject one approval step — to the customer (terminal → REJECTED) or, via returnToStepId, back to an earlier approver (chain re-runs from there)", "POST", "Contract"],
"POST /api/contracts/:id/booking-requests": ["Customer submits a shipment request on a GENERAL customs contract", "POST", "Contract"],
"POST /api/contracts/:id/bookings": ["Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia)", "POST", "Contract"],
"POST /api/contracts/:id/bookings/:bookingId/complete": ["Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing", "POST", "Contract"],
"POST /api/contracts/:id/bookings/initiate": ["Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request", "POST", "Contract"],
"POST /api/contracts/:id/cancel": ["Customer cancels their own contract (blocked while a booking is live)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/declaration": ["GL ET uploads customs declaration documents (multi-file)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/delivery-order": ["GL DJ uploads Delivery Order (import) with vessel arrival + DO collected dates", "POST", "Contract"],
"POST /api/contracts/:id/clearance/documents": ["Customer uploads clearance documents (fieldname = document key)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/documents/:fileKey/replace": ["GL replaces a clearance document in place (reason required) — the previous version is kept in the file history and the new one needs approving", "POST", "Contract"],
"POST /api/contracts/:id/clearance/duty": ["GL ET sets duty/tax requirement and advises amount with notice attachment", "POST", "Contract"],
"POST /api/contracts/:id/clearance/duty-slip": ["Customer uploads duty/tax payment slip on contract", "POST", "Contract"],
"POST /api/contracts/:id/clearance/duty/dispute": ["Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/export-release": ["GL ET confirms export release after declaration", "POST", "Contract"],
"POST /api/contracts/:id/clearance/finalize": ["GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING (legacy)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/finalize-export-clearance": ["GL ET finalizes export clearance after post-booking transit permit upload", "POST", "Contract"],
"POST /api/contracts/:id/clearance/finalize-pre-clearance": ["GL ET finalizes import pre-clearance — unlocks Djibouti DO upload", "POST", "Contract"],
"POST /api/contracts/:id/clearance/ops-finalize": ["Operations finalizes self-clearance → customer may create the booking", "POST", "Contract"],
"POST /api/contracts/:id/clearance/ops-review": ["Operations reviews a customer self-clearance document (Approve | Query)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/output-documents": ["GL uploads customs output documents (IM4/EX3/…) pre-booking", "POST", "Contract"],
"POST /api/contracts/:id/clearance/release-order": ["GL DJ uploads Release Order + vessel departure date (export)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/review": ["GL ET reviews a clearance document (Approve | Query)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/ro-amendment": ["GL DJ requests port amendment when RO vessel window is too short", "POST", "Contract"],
"POST /api/contracts/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Contract"],
"POST /api/contracts/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the customs declaration", "POST", "Contract"],
"POST /api/contracts/:id/clearance/transit-permit": ["GL ET uploads import transit permit documents (multi-file)", "POST", "Contract"],
"POST /api/contracts/:id/confirm-submit": ["Confirm submit after a price change", "POST", "Contract"],
"POST /api/contracts/:id/contract/generate": ["Generate contract document → CONTRACT_READY", "POST", "Contract"],
"POST /api/contracts/:id/contract/send-signing-otp": ["Send the sudo-mode signing OTP to the contract company's registered phone (server picks the number)", "POST", "Contract"],
"POST /api/contracts/:id/contract/sign": ["Apply digital signature (customer or staff/director/ceo)", "POST", "Contract"],
"PUT /api/contracts/:id/document/articles": ["Edit this contract\\'s document articles only (per-contract; never touches the six shared templates)", "PUT", "Contract"],
"POST /api/contracts/:id/documents": ["Upload intake documents for a contract (DRAFT only)", "POST", "Contract"],
"POST /api/contracts/:id/generate-price": ["Generate unit-rate breakdown (no totals at contract phase)", "POST", "Contract"],
"POST /api/contracts/:id/milestones/:code/complete": ["GL marks a pre-booking (contract) milestone complete", "POST", "Contract"],
"POST /api/contracts/:id/renew": ["Create a renewal draft linked via renewalOfId", "POST", "Contract"],
"POST /api/contracts/:id/resume": ["Staff lift a suspension — contract returns to its prior status", "POST", "Contract"],
"POST /api/contracts/:id/staff/accept": ["Staff accept → set validity window + start approval chain", "POST", "Contract"],
"POST /api/contracts/:id/staff/reject": ["Staff reject contract", "POST", "Contract"],
"POST /api/contracts/:id/staff/request-changes": ["Staff return contract for customer updates", "POST", "Contract"],
"POST /api/contracts/:id/submit": ["Customer submit contract (freezes contract_rate_snapshots)", "POST", "Contract"],
"POST /api/contracts/:id/suspend": ["Staff freeze a signed contract (reversible, any post-signature step)", "POST", "Contract"],
"POST /api/contracts/:id/validate-shipment": ["Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created)", "POST", "Contract"],
"POST /api/contracts/booking-requests/:reqId/accept": ["GL marks a shipment request accepted + links the created booking", "POST", "Contract"],
"POST /api/contracts/booking-requests/:reqId/cancel": ["Customer cancels their own pending shipment request", "POST", "Contract"],
"POST /api/contracts/booking-requests/:reqId/reject": ["GL rejects a shipment request", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/documents": ["GL uploads post-booking operational documents (DO/RO/T1/…)", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/duty": ["GL ET advises duty & tax amount + declaration serial", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/duty-slip": ["Customer uploads the duty/tax payment slip", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/final-invoice": ["GL DJ raises the post-offload final invoice (amount + invoice document)", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/final-invoice-slip": ["Customer attaches the payment slip for the final invoice", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/final-invoice/approve": ["Customer approves the drafted final invoice — unlocks the payment slip", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/final-invoice/confirm": ["GL (ET or DJ) confirms the payment slip — settles the final invoice", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/incidents": ["GL DJ logs a cargo exception with photo evidence", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/milestones/:code/complete": ["GL / Ops / Terminal marks a post-booking milestone complete", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/risk": ["GL ET assigns a customs risk level (GREEN/YELLOW/RED)", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/second-duty": ["GL ET advises (or skips) the post-arrival additional duty/tax round (import)", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/second-duty-slip": ["Customer attaches the additional duty/tax payment slip", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/station-assign": ["GL station manager routes the shipment + binds staff", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/t1-close": ["Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export)", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/t1-documents": ["GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/transport-document": ["GL ET uploads export transit permit documents (multi-file)", "POST", "Contract"],
"POST /api/gl-exchange/:entityId": ["Share a document with the other GL desk", "POST", "Contract"],
"PATCH /api/gl-exchange/documents/:documentId": ["Uploader edits a shared document (title, visibility, file)", "PATCH", "Contract"],
"DELETE /api/gl-exchange/documents/:documentId": ["Uploader removes a shared document", "DELETE", "Contract"],
// Contract Template
"POST /api/contract-templates": ["Create a bulk contract template for a (cargo type, customs option) pair", "POST", "Contract Template"],
"PATCH /api/contract-templates/:code": ["Update template metadata (name, title, recitals, active flag)", "PATCH", "Contract Template"],
"DELETE /api/contract-templates/:code": ["Delete a staff-created bulk template (system templates refuse)", "DELETE", "Contract Template"],
"POST /api/contract-templates/:code/articles": ["Add an article to the template", "POST", "Contract Template"],
"PUT /api/contract-templates/:code/articles": ["Replace the full ordered article list (used for reorder)", "PUT", "Contract Template"],
"PATCH /api/contract-templates/:code/articles/:articleId": ["Update an article's title or body", "PATCH", "Contract Template"],
"DELETE /api/contract-templates/:code/articles/:articleId": ["Remove an article from the template", "DELETE", "Contract Template"],
"POST /api/contract-templates/:code/preview": ["Render an HTML preview of the template against mock contract data", "POST", "Contract Template"],
// Driver
"POST /api/drivers": ["Create a new driver", "POST", "Driver"],
"PATCH /api/drivers/:id": ["Update a driver", "PATCH", "Driver"],
"DELETE /api/drivers/:id": ["Delete a driver", "DELETE", "Driver"],
"POST /api/drivers/:id/documents": ["Upload driver documents (code driver_docs)", "POST", "Driver"],
"DELETE /api/drivers/:id/documents/:fileId": ["Delete a driver document", "DELETE", "Driver"],
// Dropdown Setting
"POST /api/dropdown-settings": ["Create a new dropdown setting", "POST", "Dropdown Setting"],
"PATCH /api/dropdown-settings/:id": ["Update a dropdown setting's metadata", "PATCH", "Dropdown Setting"],
"DELETE /api/dropdown-settings/:id": ["Soft-delete a dropdown setting", "DELETE", "Dropdown Setting"],
"POST /api/dropdown-settings/:id/options": ["Append a single option to a setting", "POST", "Dropdown Setting"],
"PUT /api/dropdown-settings/:id/options": ["Replace the full option list for a setting", "PUT", "Dropdown Setting"],
"PATCH /api/dropdown-settings/options/:optionId": ["Update a single option", "PATCH", "Dropdown Setting"],
"DELETE /api/dropdown-settings/options/:optionId": ["Soft-delete a single option", "DELETE", "Dropdown Setting"],
// EIMS Invoice
"POST /api/invoices/:id/eims/register": ["Register the invoice with MoR EIMS. Idempotent — an invoice that already has an IRN is returned unchanged", "POST", "EIMS Invoice"],
"POST /api/invoices/:id/eims/resolve": ["Resolve an unacknowledged submission: record the IRN confirmed with MoR, or discard it. Clears the system-wide block", "POST", "EIMS Invoice"],
"POST /api/invoices/:id/eims/verify": ["Verify the invoice's stored IRN against EIMS", "POST", "EIMS Invoice"],
// Exchange Setting
"PATCH /api/exchange-settings": ["Set the USD→ETB fallback by hand (used only while CBE is unreachable)", "PATCH", "Exchange Setting"],
// Facility
"POST /api/facilities": ["Create a new facility", "POST", "Facility"],
"PATCH /api/facilities/:id": ["Update a facility", "PATCH", "Facility"],
"DELETE /api/facilities/:id": ["Delete a facility (soft delete)", "DELETE", "Facility"],
// Fayda Verification
"POST /api/fayda/verification/start": ["Start a VeriFayda 2.0 verification session", "POST", "Fayda Verification"],
// File Upload Setting
"POST /api/file-upload-settings": ["Create a new file upload setting", "POST", "File Upload Setting"],
"PATCH /api/file-upload-settings/:id": ["Update a file upload setting's metadata", "PATCH", "File Upload Setting"],
"DELETE /api/file-upload-settings/:id": ["Soft-delete a file upload setting", "DELETE", "File Upload Setting"],
"POST /api/file-upload-settings/:id/fields": ["Append a single field to a setting", "POST", "File Upload Setting"],
"PUT /api/file-upload-settings/:id/fields": ["Replace the full field list for a setting", "PUT", "File Upload Setting"],
"PATCH /api/file-upload-settings/fields/:fieldId": ["Update a single field", "PATCH", "File Upload Setting"],
"DELETE /api/file-upload-settings/fields/:fieldId": ["Soft-delete a single field", "DELETE", "File Upload Setting"],
// First Mile
"POST /api/first-mile": ["Create a first-mile leg", "POST", "First Mile"],
"PATCH /api/first-mile/:id": ["Update a first-mile leg", "PATCH", "First Mile"],
"DELETE /api/first-mile/:id": ["Soft-delete a first-mile leg", "DELETE", "First Mile"],
"POST /api/first-mile/:id/distances": ["Set per-vehicle actual distances (does not generate an invoice)", "POST", "First Mile"],
"POST /api/first-mile/:id/invoice": ["Generate the first-mile delivery-fee invoice", "POST", "First Mile"],
"POST /api/first-mile/:id/vehicles": ["Set the vehicles assigned to a first-mile pickup (multi-truck)", "POST", "First Mile"],
"POST /api/first-mile/accept/:reference": ["Accept a paid booking and create a first-mile leg", "POST", "First Mile"],
// Fuel
"POST /api/fuel/purchases": ["Record fuel purchase", "POST", "Fuel"],
// GPS Tracking
"POST /api/gps/devices": ["Register a GPS tracker", "POST", "GPS Tracking"],
"PATCH /api/gps/devices/:id": ["Update a GPS tracker (name / assigned vehicle)", "PATCH", "GPS Tracking"],
"DELETE /api/gps/devices/:id": ["Delete a GPS tracker", "DELETE", "GPS Tracking"],
// Import Operation
"POST /api/import-operations/customs/:bookingId/declaration": ["Batch 12: record declaration serial number", "POST", "Import Operation"],
"POST /api/import-operations/customs/:bookingId/documents": ["Batch 12: upload IM4/IM5/T1/permit/payment-slip documents", "POST", "Import Operation"],
"POST /api/import-operations/customs/:bookingId/duties-taxes-paid": ["Batch 12: mark duties and taxes paid", "POST", "Import Operation"],
"POST /api/import-operations/customs/:bookingId/notify-duties-taxes": ["Batch 12: notify duties and taxes", "POST", "Import Operation"],
"POST /api/import-operations/customs/:bookingId/release-permitted": ["Batch 12: mark import release permitted", "POST", "Import Operation"],
"POST /api/import-operations/customs/:bookingId/risk": ["Batch 12: assign customs risk", "POST", "Import Operation"],
"POST /api/import-operations/djibouti-incidents": ["Batch 8: report a Djibouti import incident / exception", "POST", "Import Operation"],
"POST /api/import-operations/empty-container-returns": ["Batch 16: create an empty container return record", "POST", "Import Operation"],
"POST /api/import-operations/empty-container-returns/:id/status": ["Batch 16: advance empty container return workflow", "POST", "Import Operation"],
// Incident
"POST /api/incidents": ["Report an incident", "POST", "Incident"],
"PATCH /api/incidents/:id": ["Update an incident", "PATCH", "Incident"],
"DELETE /api/incidents/:id": ["Delete an incident", "DELETE", "Incident"],
// Interchange Document
"PATCH /api/interchange-documents/:id/acknowledge": ["Acknowledge an interchange document", "PATCH", "Interchange Document"],
"PATCH /api/interchange-documents/:id/dispute": ["Dispute an interchange document", "PATCH", "Interchange Document"],
"POST /api/interchange-documents/generate-from-schedule": ["Generate interchange document from a train schedule handover", "POST", "Interchange Document"],
// Last Mile
"POST /api/last-mile": ["Create a last-mile leg", "POST", "Last Mile"],
"PATCH /api/last-mile/:id": ["Update a last-mile leg", "PATCH", "Last Mile"],
"DELETE /api/last-mile/:id": ["Soft-delete a last-mile leg", "DELETE", "Last Mile"],
"POST /api/last-mile/:id/detention-times": ["Set each truck\\'s own detention window (arrived at destination / returned)", "POST", "Last Mile"],
"POST /api/last-mile/:id/distances": ["Set per-vehicle actual distances (does not generate an invoice)", "POST", "Last Mile"],
"POST /api/last-mile/:id/invoice": ["Generate the delivery-fee invoice for a last-mile leg", "POST", "Last Mile"],
"POST /api/last-mile/:id/proof-of-delivery": ["Record proof of delivery (signature + photos) and complete the leg", "POST", "Last Mile"],
"POST /api/last-mile/:id/vehicles": ["Set the vehicles assigned to a last-mile delivery (multi-truck)", "POST", "Last Mile"],
"POST /api/last-mile/:id/warehouse-gate-times": ["Set each truck\\'s warehouse gate arrival/departure times", "POST", "Last Mile"],
"POST /api/last-mile/accept/:reference": ["Accept a paid booking and create a last-mile leg", "POST", "Last Mile"],
// Last Mile Request
"POST /api/last-mile-requests/:id/approve": ["Truck & Machinery chief approves the request — the advance defaults to the live last-mile rate; LM contract becomes signable and the advance invoice follows the customer signature", "POST", "Last Mile Request"],
"POST /api/last-mile-requests/:id/contract/sign": ["Customer agrees and signs the LM contract — then the advance invoice is issued", "POST", "Last Mile Request"],
"POST /api/last-mile-requests/:id/reject": ["Truck & Machinery chief rejects the request with a reason", "POST", "Last Mile Request"],
"POST /api/last-mile-requests/:id/submit": ["Customer confirms which containers go via EDR last-mile", "POST", "Last Mile Request"],
// Locomotive
"POST /api/locomotives": ["Create a locomotive", "POST", "Locomotive"],
"PATCH /api/locomotives/:id": ["Update a locomotive", "PATCH", "Locomotive"],
"POST /api/locomotives/:id/decommission": ["Decommission a locomotive", "POST", "Locomotive"],
"DELETE /api/locomotives/:id/permanent": ["Permanently delete a locomotive (irreversible; refused if any train references it)", "DELETE", "Locomotive"],
// Maintenance
"POST /api/maintenance/costs": ["Record maintenance cost", "POST", "Maintenance"],
"POST /api/maintenance/intervals": ["Define/adjust a service interval (e.g. oil change every 10,000 km)", "POST", "Maintenance"],
"DELETE /api/maintenance/intervals/:id": ["Deactivate a service interval (stops auto-scheduling)", "DELETE", "Maintenance"],
"POST /api/maintenance/parts": ["Create part", "POST", "Maintenance"],
"PATCH /api/maintenance/parts/:id": ["Update part", "PATCH", "Maintenance"],
"DELETE /api/maintenance/parts/:id": ["Delete part", "DELETE", "Maintenance"],
"POST /api/maintenance/schedules": ["Schedule maintenance", "POST", "Maintenance"],
"PATCH /api/maintenance/schedules/:id": ["Update maintenance schedule", "PATCH", "Maintenance"],
"POST /api/maintenance/warranties": ["Create warranty", "POST", "Maintenance"],
"DELETE /api/maintenance/warranties/:id": ["Delete warranty", "DELETE", "Maintenance"],
"POST /api/maintenance/work-orders": ["Create work order", "POST", "Maintenance"],
"PATCH /api/maintenance/work-orders/:id": ["Update work order", "PATCH", "Maintenance"],
"DELETE /api/maintenance/work-orders/:id": ["Delete work order", "DELETE", "Maintenance"],
// Notification Inbox
"PATCH /api/notifications/:id/read": ["Mark one of my notifications as read", "PATCH", "Notification Inbox"],
"POST /api/notifications/read-all": ["Mark all my notifications as read", "POST", "Notification Inbox"],
// Organization User
"PUT /api/backoffice/organizations/:orgId/employee-users/:userId/roles": ["Replace org-scoped roles assigned to an employee user", "PUT", "Organization User"],
"POST /api/backoffice/organizations/:orgId/users": ["Create an organization user without assigning positions", "POST", "Organization User"],
// OTP
"POST /api/otp/send": ["Send OTP", "POST", "OTP"],
"POST /api/otp/verify": ["Verify OTP", "POST", "OTP"],
// Password Reset
"POST /api/auth/forgot-password/request": ["Send a password-reset code to the account's email AND phone", "POST", "Password Reset"],
"POST /api/auth/forgot-password/resolve-link": ["Validate a staff-issued reset link and return its set-password ticket", "POST", "Password Reset"],
"POST /api/auth/forgot-password/verify": ["Exchange a valid reset code for a single-use set-password ticket", "POST", "Password Reset"],
"POST /api/backoffice/customers/:companyId/reset-password": ["Send a password-reset link to a customer's primary contact", "POST", "Password Reset"],
// Payment
"POST /api/billing/invoices/:id/confirm-offline": ["Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance", "POST", "Payment"],
"POST /api/billing/my-invoices/:id/confirm": ["Confirm an OTP-debit payment (CAC Bank) for one of the customer's invoices", "POST", "Payment"],
"POST /api/billing/my-invoices/:id/pay": ["Initiate payment for one of the customer's invoices", "POST", "Payment"],
"POST /api/internal/payments/bill-query": ["Live still-payable check + payer name for a CBE bill (called while CBE is on the line)", "POST", "Payment"],
"POST /api/internal/payments/mark-paid": ["Apply a payment.succeeded / payment.failed event from the payment service (idempotent)", "POST", "Payment"],
"POST /api/payments/initiate": ["Initiate payment for an invoice", "POST", "Payment"],
"POST /api/payments/redirect-success/:bookingId": ["Success-redirect ack: mark payment processing + invoice PAYMENT_PROCESSING (webhook remains source of truth)", "POST", "Payment"],
// Priority Config
"POST /api/priority-configs": ["Create a priority config", "POST", "Priority Config"],
"PATCH /api/priority-configs/:id": ["Update a priority config", "PATCH", "Priority Config"],
"DELETE /api/priority-configs/:id": ["Soft-delete a priority config", "DELETE", "Priority Config"],
"POST /api/priority-configs/:id/move-order": ["Move a priority config up or down in display order", "POST", "Priority Config"],
"POST /api/priority-configs/reorder": ["Bulk reorder priority configs by ID list", "POST", "Priority Config"],
// Priority Rule Change Request
"POST /api/priority-rule-change-requests": ["Submit a priority-rule change for approval", "POST", "Priority Rule Change Request"],
"POST /api/priority-rule-change-requests/:id/approve": ["Approve and apply a pending change", "POST", "Priority Rule Change Request"],
"POST /api/priority-rule-change-requests/:id/reject": ["Reject a pending change", "POST", "Priority Rule Change Request"],
// Procurement
"POST /api/procurement/acquisitions": ["Create an asset acquisition", "POST", "Procurement"],
"PATCH /api/procurement/acquisitions/:id": ["Update an asset acquisition", "PATCH", "Procurement"],
"DELETE /api/procurement/acquisitions/:id": ["Delete an asset acquisition", "DELETE", "Procurement"],
"POST /api/procurement/disposals": ["Create an asset disposal", "POST", "Procurement"],
"DELETE /api/procurement/disposals/:id": ["Delete an asset disposal", "DELETE", "Procurement"],
"POST /api/procurement/vendors": ["Create a vendor", "POST", "Procurement"],
"PATCH /api/procurement/vendors/:id": ["Update a vendor", "PATCH", "Procurement"],
"DELETE /api/procurement/vendors/:id": ["Delete a vendor", "DELETE", "Procurement"],
// Rate
"POST /api/rates": ["Create a rate (DRAFT)", "POST", "Rate"],
"PATCH /api/rates/:id": ["Update a DRAFT rate", "PATCH", "Rate"],
"DELETE /api/rates/:id": ["Soft-delete a rate", "DELETE", "Rate"],
"POST /api/rates/:id/approve": ["CEO approves a rate", "POST", "Rate"],
"POST /api/rates/:id/submit": ["Submit rate for CEO approval", "POST", "Rate"],
// Rate Change Request
"POST /api/rate-change-requests": ["Propose a change to a LIVE rate", "POST", "Rate Change Request"],
"POST /api/rate-change-requests/:id/approve": ["Approve a rate change and put it into effect", "POST", "Rate Change Request"],
"POST /api/rate-change-requests/:id/reject": ["Reject a rate change — the rate keeps its current value", "POST", "Rate Change Request"],
// Route
"POST /api/routes": ["Create route", "POST", "Route"],
"PATCH /api/routes/:id": ["Update route", "PATCH", "Route"],
"DELETE /api/routes/:id": ["Deactivate route", "DELETE", "Route"],
"DELETE /api/routes/:id/permanent": ["Permanently delete a route (irreversible; refused while any train schedule references it)", "DELETE", "Route"],
// Schedule
// NOTE: duplicate route — also declared in modules/scheduling-reschedule/scheduling-reschedule.controller.ts:52.
// Two controllers register this same path; Nest serves whichever module loads first.
"POST /api/train-scheduling/schedules/:id/maintenance": ["Reschedule train for maintenance (new departure + rebalance)", "POST", "Schedule"],
"POST /api/train-scheduling/schedules/:id/reschedule/execute": ["Execute a confirmed reschedule plan", "POST", "Schedule"],
"POST /api/train-scheduling/schedules/:id/reschedule/preview": ["Preview reschedule / government preempt plan", "POST", "Schedule"],
// Service Type
"POST /api/service-types": ["Create a service type", "POST", "Service Type"],
"PATCH /api/service-types/:id": ["Update a service type", "PATCH", "Service Type"],
"DELETE /api/service-types/:id": ["Soft-delete a service type", "DELETE", "Service Type"],
"POST /api/service-types/:id/move-order": ["Move a service type up or down in display order", "POST", "Service Type"],
"POST /api/service-types/reorder": ["Bulk reorder service types by ID list", "POST", "Service Type"],
// Shipping Line
"POST /api/shipping-lines": ["Create a shipping line", "POST", "Shipping Line"],
"PATCH /api/shipping-lines/:id": ["Update a shipping line", "PATCH", "Shipping Line"],
"DELETE /api/shipping-lines/:id": ["Soft-delete a shipping line", "DELETE", "Shipping Line"],
// Signature
"PUT /api/me/signature": ["Create or update the reusable saved signature", "PUT", "Signature"],
// Support Chat
"POST /api/support/agent/conversations": ["Start chatting with a company (returns the thread if one exists)", "POST", "Support Chat"],
"POST /api/support/agent/conversations/:id/messages": ["Reply as an agent, optionally with attachments", "POST", "Support Chat"],
"POST /api/support/agent/conversations/:id/read": ["Mark a thread read (agent side)", "POST", "Support Chat"],
"POST /api/support/conversation/messages": ["Send a message as the customer (optionally with attachments), opening the thread if needed", "POST", "Support Chat"],
"POST /api/support/conversation/read": ["Mark my company's thread read (customer side)", "POST", "Support Chat"],
// Support Content
"PATCH /api/support-content/documents/:slug": ["Replace a document's payload, recording a new version", "PATCH", "Support Content"],
"POST /api/support-content/documents/:slug/versions/:version/restore": ["Restore a version — re-saves it as a new version, never destructive", "POST", "Support Content"],
"POST /api/support-content/media": ["Upload an image or video for a help section", "POST", "Support Content"],
// Train
"POST /api/trains": ["Register a new train", "POST", "Train"],
"PATCH /api/trains/:id": ["Update a train", "PATCH", "Train"],
"DELETE /api/trains/:id": ["Delete a train", "DELETE", "Train"],
// Train Build
"POST /api/train-builder": ["Build a train: code + yard + 2+ locomotives (+ optional wagons)", "POST", "Train Build"],
"DELETE /api/train-builder/:id": ["Disband the train (release wagons and locomotives)", "DELETE", "Train Build"],
"POST /api/train-builder/:id/activate": ["Reactivate a deactivated train back to AVAILABLE", "POST", "Train Build"],
"POST /api/train-builder/:id/deactivate": ["Deactivate the train (park it) — only allowed with no active schedule", "POST", "Train Build"],
"PATCH /api/train-builder/:id/details": ["Edit the train's name and fixed import/export run numbers", "PATCH", "Train Build"],
"PUT /api/train-builder/:id/locomotives": ["Replace the locomotive set (minimum 1, same yard)", "PUT", "Train Build"],
"POST /api/train-builder/:id/reorder-wagons": ["Persist a drag-reorder of the full consist", "POST", "Train Build"],
"POST /api/train-builder/:id/wagons": ["Append AVAILABLE wagons from the train's yard to the consist", "POST", "Train Build"],
"DELETE /api/train-builder/:id/wagons/:wagonId": ["Detach one wagon from the consist", "DELETE", "Train Build"],
"POST /api/train-builder/:id/wagons/:wagonId/maintenance": ["Detach one wagon and move it to MAINTENANCE status", "POST", "Train Build"],
"PATCH /api/train-builder/:id/yard": ["Relocate the train — its locomotives and wagons move to the new yard with it", "PATCH", "Train Build"],
// Train Schedule
"POST /api/train-scheduling/bookings/:bookingId/allocate": ["Staff: place a paid booking onto a fitting train (notifies customer on date change)", "POST", "Train Schedule"],
"POST /api/train-scheduling/bookings/:bookingId/expire": ["Staff: expire a reservation and free its capacity", "POST", "Train Schedule"],
"POST /api/train-scheduling/bookings/:bookingId/mark-paid": ["Staff: mark a reserved booking paid and allocate it now", "POST", "Train Schedule"],
"POST /api/train-scheduling/bookings/:bookingId/move-schedule": ["Re-point a booking to another OPEN same-route schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/bulk/preview": ["Preview a bulk train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/bulk/schedules": ["Create a bulk train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/bulk/schedules/:id/assign-bookings": ["Assign bulk bookings to a train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/bulk/schedules/:id/cancel": ["Cancel bulk train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/container/preview": ["Preview a container train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/container/schedules": ["Create a container train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/container/schedules/:id/assign-bookings": ["Assign container bookings to a train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/container/schedules/:id/cancel": ["Cancel container train schedule", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/global-rules": ["Update global train scheduling rules (singleton)", "PATCH", "Train Schedule"],
"POST /api/train-scheduling/preview": ["Preview a mixed-capable train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/adjust-consist": ["Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/arrive": ["Mark a dispatched train arrived (move assets to destination yard, free assets)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/assign-bookings": ["Assign bookings to a train schedule (mixed-capable)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/assign-unassigned-booking": ["Assign one linked unallocated booking to wagons (preserves existing assignments)", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/booking-window": ["Open or close a schedule booking window", "PATCH", "Train Schedule"],
"DELETE /api/train-scheduling/schedules/:id/bookings/:bookingId": ["Unassign a booking from a train schedule", "DELETE", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/bookings/:bookingId/load": ["Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/bookings/:bookingId/unload": ["Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/checkpoints": ["Log the train passing a station (final station triggers arrival)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/confirm-loading": ["Confirm cargo loaded on the train (any direction; unblocks import-Djibouti dispatch)", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/container-items/:itemId": ["Update a container number on a wagon slot", "PATCH", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/dispatch": ["Dispatch a scheduled train", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/doc-review-complete": ["Staff finished document review early — run the batch/payment phase now (applies to the whole route-day group)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/finalize": ["Finalize a draft train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/import-djibouti/depart": ["Depart loaded import train from Djibouti", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/import-djibouti/documents": ["Upload/check an import Djibouti-side document", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/import-djibouti/gatepass-granted": ["Mark import Djibouti gatepass permission granted", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/import-djibouti/load-list": ["Generate import load list / marshalling document summary", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/import-djibouti/loaded-on-train": ["Confirm import cargo loaded on train at Djibouti", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/import-djibouti/ready-for-loading": ["Mark import train ready for loading at Djibouti", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/import-loading-status": ["Mark import bookings loaded/unloaded on this schedule (tracking only, does not affect dispatch)", "PATCH", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/intercity/:bookingId/load": ["Confirm intercity cargo loaded (train must be at the booking's origin yard)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/intercity/:bookingId/unload": ["Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/intercity/accept": ["Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking)", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/loading-status": ["Mark bookings loaded/unloaded on this schedule (any direction, pre-dispatch only)", "PATCH", "Train Schedule"],
// NOTE: duplicate route — also declared in modules/train-scheduling/controllers/train-scheduling.controller.ts:798.
// Two controllers register this same path; Nest serves whichever module loads first.
"POST /api/train-scheduling/schedules/:id/maintenance [modules/train-scheduling/controllers/train-scheduling.controller.ts]": ["Maintenance reschedule: move the train to a new departure with every allocated booking aboard — links, wagons and window settings unchanged", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/pin-wagons": ["Pin physical wagons to train set slots", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/run-allocation": ["Run wagon-level allocation for all eligible linked bookings", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/run-batch": ["Manually run the batch fill for a schedule", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/schedule-date": ["Reschedule a train's departure date — only before the booking window opens, and only if the new date still leaves room for the booking lead window", "PATCH", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/switch-government-booking": ["Switch out commercial bookings to allocate a government booking in their place", "POST", "Train Schedule"],
"DELETE /api/train-scheduling/schedules/:id/wagons/:trainSetWagonId": ["Remove an empty wagon slot from a train", "DELETE", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/wagons/:wagonId/move-load": ["Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/window-rule": ["Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens", "PATCH", "Train Schedule"],
// Transit Agent
"POST /api/transit-agents": ["Create a transit agent", "POST", "Transit Agent"],
"PATCH /api/transit-agents/:id": ["Update a transit agent", "PATCH", "Transit Agent"],
"DELETE /api/transit-agents/:id": ["Soft-delete a transit agent", "DELETE", "Transit Agent"],
// Truck Type
"POST /api/truck-types": ["Create a truck type", "POST", "Truck Type"],
"PATCH /api/truck-types/:id": ["Update a truck type", "PATCH", "Truck Type"],
"DELETE /api/truck-types/:id": ["Soft-delete a truck type", "DELETE", "Truck Type"],
// User Trade Access
"PUT /api/user-trade-access/:userId": ["Set the trade directions a backoffice user may see", "PUT", "User Trade Access"],
// Vehicle
"POST /api/vehicles": ["Create a new vehicle", "POST", "Vehicle"],
"PATCH /api/vehicles/:id": ["Update a vehicle", "PATCH", "Vehicle"],
"DELETE /api/vehicles/:id": ["Delete a vehicle", "DELETE", "Vehicle"],
// Wagon
"POST /api/wagons": ["Create a new wagon", "POST", "Wagon"],
"PATCH /api/wagons/:id": ["Update a wagon", "PATCH", "Wagon"],
"DELETE /api/wagons/:id": ["Delete a wagon", "DELETE", "Wagon"],
"POST /api/wagons/:id/assign-train": ["Assign wagon to a train", "POST", "Wagon"],
"DELETE /api/wagons/:id/permanent": ["Permanently delete a wagon (irreversible; refused if it has movements, containers or train-set slots)", "DELETE", "Wagon"],
"POST /api/wagons/:id/unassign-train": ["Unassign wagon from train", "POST", "Wagon"],
"POST /api/wagons/bulk-status": ["Set the status of multiple wagons (audited in wagon_status_logs)", "POST", "Wagon"],
"POST /api/wagons/bulk-transfer": ["Transfer multiple wagons to a destination yard", "POST", "Wagon"],
// Wagon Transfer Request
"POST /api/wagon-transfer-requests": ["File a count-only wagon-transfer request", "POST", "Wagon Transfer Request"],
"POST /api/wagon-transfer-requests/:id/cancel": ["Withdraw a request that has not moved any wagon yet (use close-short once wagons have moved)", "POST", "Wagon Transfer Request"],
"POST /api/wagon-transfer-requests/:id/close-short": ["OCC: end the request with fewer wagons than asked for — what moved stays, the requester is told the shortfall", "POST", "Wagon Transfer Request"],
"POST /api/wagon-transfer-requests/:id/fulfill": ["OCC: pick wagons and execute the transfer", "POST", "Wagon Transfer Request"],
"POST /api/wagon-transfer-requests/bulk-fulfill": ["OCC: accept-and-execute a subset of pending requests (auto-picks available wagons; the rest stay PENDING)", "POST", "Wagon Transfer Request"],
// Wagon Type
"POST /api/wagon-types": ["Create a wagon type", "POST", "Wagon Type"],
"PATCH /api/wagon-types/:id": ["Update a wagon type", "PATCH", "Wagon Type"],
"DELETE /api/wagon-types/:id": ["Soft-delete a wagon type", "DELETE", "Wagon Type"],
// Warehouse
"POST /api/warehouse-allocation-rules": ["Create a warehouse allocation rule", "POST", "Warehouse"],
"PATCH /api/warehouse-allocation-rules/:id": ["Update a warehouse allocation rule", "PATCH", "Warehouse"],
"DELETE /api/warehouse-allocation-rules/:id": ["Delete a warehouse allocation rule", "DELETE", "Warehouse"],
"POST /api/warehouse-allocation/preview": ["Preview the yard/warehouse/zone a booking would be allocated to", "POST", "Warehouse"],
"POST /api/warehouse-fee-rules": ["Create a storage / demurrage fee rule", "POST", "Warehouse"],
"PATCH /api/warehouse-fee-rules/:id": ["Update a fee rule", "PATCH", "Warehouse"],
"DELETE /api/warehouse-fee-rules/:id": ["Delete a fee rule", "DELETE", "Warehouse"],
"POST /api/warehouse-fees/accrual/:inventoryId/acknowledge": ["Acknowledge / snooze an item fee-accrual alert", "POST", "Warehouse"],
"DELETE /api/warehouse-fees/accrual/:inventoryId/acknowledge": ["Remove an accrual acknowledgement (re-surface for alerts)", "DELETE", "Warehouse"],
"POST /api/warehouses": ["Create warehouse", "POST", "Warehouse"],
"PATCH /api/warehouses/:id": ["Update warehouse", "PATCH", "Warehouse"],
"POST /api/warehouses/:warehouseId/yards": ["Create a yard within a warehouse", "POST", "Warehouse"],
// Warehouse Fee Invoice
"POST /api/last-mile/:id/generate-truck-detention-invoice": ["Generate a truck-detention invoice for a last-mile leg (per truck per day)", "POST", "Warehouse Fee Invoice"],
"PATCH /api/warehouse-fee-invoices/:id/cancel": ["Cancel a warehouse fee invoice", "PATCH", "Warehouse Fee Invoice"],
"POST /api/warehouse-fee-invoices/:id/pay": ["Record a payment against a warehouse fee invoice", "POST", "Warehouse Fee Invoice"],
"POST /api/warehouse-fee-invoices/:id/pay-online": ["Initiate Telebirr/Waafi payment for a warehouse fee invoice", "POST", "Warehouse Fee Invoice"],
"POST /api/warehouse-inventory/:id/generate-fee-invoice": ["Generate a warehouse fee invoice from Batch 5 fee calculation", "POST", "Warehouse Fee Invoice"],
// Warehouse Inspection Report
"PATCH /api/warehouse-inspection-reports/:id": ["Update an inspection report", "PATCH", "Warehouse Inspection Report"],
"POST /api/warehouse-inspection-reports/:id/attachments": ["Upload inspection images / documents", "POST", "Warehouse Inspection Report"],
"POST /api/warehouse-inventory/:inventoryId/inspection-reports": ["Create an inspection / damage report for an inventory item", "POST", "Warehouse Inspection Report"],
// Warehouse Inventory
"POST /api/warehouse-inventory/:id/deliver": ["Deliver import goods to the customer + capture proof of delivery", "POST", "Warehouse Inventory"],
"PATCH /api/warehouse-inventory/:id/dispatch": ["Mark loaded inventory DISPATCHED (left the terminal)", "PATCH", "Warehouse Inventory"],
"POST /api/warehouse-inventory/:id/gate-clearance": ["Final terminal release / gate clearance (blocked while fees unpaid)", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/:id/load": ["Load READY_FOR_LOADING inventory onto a wagon", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/:id/move": ["Move inventory to another warehouse/yard/zone", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/:id/ready-for-loading": ["Mark reserved inventory READY_FOR_LOADING", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/:id/ready-for-pickup": ["Mark inspected IMPORT inventory READY_FOR_PICKUP", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/:id/release": ["Issue a DO / release order for ready-for-pickup inventory", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/:id/store": ["Mark received inventory as STORED (optional explicit warehouse/yard/zone)", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/auto-load-ready": ["Auto-load READY_FOR_LOADING inventory with PAID bookings", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/auto-unload-arrived": ["Bulk auto-unload all arrived bookings into the warehouse", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/bookings/:bookingId/approve-delivery": ["Approve delivery — customer records their full name (signature optional)", "POST", "Warehouse Inventory"],
"PATCH /api/warehouse-inventory/bookings/:bookingId/double-handling": ["Record Yes/No double handling after unloading (Yes applies the double-handling fee rule)", "PATCH", "Warehouse Inventory"],
"POST /api/warehouse-inventory/bookings/:bookingId/request-handover-signature": ["Ask the customer to sign the handover (creates one if none, then notifies)", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/bookings/:bookingId/unload": ["Unload a single arrived booking into a location", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/bulk-dispatch-export": ["Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/bulk-mark-inspected": ["Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING)", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/export/auto-unload-at-djibouti": ["Unload all eligible export items assigned to an arrived Djibouti-side train", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/handovers/:handoverId/sign": ["Customer signs one handover (EDR last-mile: one signature per truck)", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/import/auto-unload-arrived-bookings": ["Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/receive": ["Receive inventory at a warehouse location", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/receive-bulk": ["Bulk-receive selected eligible PAID bookings into a location", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/reserve": ["Reserve stored inventory for a PAID booking", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/train/:scheduleId/load": ["Load selected inventory items onto their allocated wagons for a train", "POST", "Warehouse Inventory"],
// Warehouse Yard
"PATCH /api/warehouse-yards/:id": ["Update warehouse yard", "PATCH", "Warehouse Yard"],
"POST /api/warehouse-yards/:yardId/zones": ["Create a zone within a yard", "POST", "Warehouse Yard"],
// Warehouse Zone
"PATCH /api/warehouse-zones/:id": ["Update warehouse zone", "PATCH", "Warehouse Zone"],
// Weight Limit Rule
"POST /api/weight-limit-rules": ["Create a weight limit rule", "POST", "Weight Limit Rule"],
"PATCH /api/weight-limit-rules/:id": ["Update a weight limit rule", "PATCH", "Weight Limit Rule"],
"DELETE /api/weight-limit-rules/:id": ["Soft-delete a weight limit rule", "DELETE", "Weight Limit Rule"],
// Yard
"POST /api/yards": ["Create a yard", "POST", "Yard"],
"PATCH /api/yards/:id": ["Update a yard", "PATCH", "Yard"],
"DELETE /api/yards/:id": ["Soft-delete a yard", "DELETE", "Yard"],
"POST /api/yards/:id/move-order": ["Move a yard up or down in display order", "POST", "Yard"],
"POST /api/yards/reorder": ["Bulk reorder yards by ID list", "POST", "Yard"],
// Yard Distance
"POST /api/yard-distances": ["Create a yard distance", "POST", "Yard Distance"],
"PATCH /api/yard-distances/:id": ["Update a yard distance", "PATCH", "Yard Distance"],
"DELETE /api/yard-distances/:id": ["Soft-delete a yard distance", "DELETE", "Yard Distance"],
};
module.exports = AUDIT_ENDPOINTS;

View File

@@ -90,6 +90,10 @@ export const TrainSchedulingReschedule = () =>
export const TrainSchedulingRulesManage = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.rulesManage);
/** Edit a schedule's operational run numbers (train + voyage) before dispatch. */
export const TrainSchedulingEditTrainNumber = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.editTrainNumber);
/**
* Fleet guards take an optional granular per-resource key (locomotives:create,
* wagons:delete, …). The legacy coarse fleet:view / fleet:manage keys remain

View File

@@ -0,0 +1,81 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Backoffice audit trail for every state-changing freight endpoint.
*
* No `updated_at` / `deleted_at` columns, unlike every other table here: audit
* rows are insert-only evidence. A soft-delete column would let an actor erase
* their own trail and TypeORM would then hide those rows from default queries
* silently — see the entity comment.
*
* `user_id` intentionally carries NO foreign key to the `iam` schema.
* Cross-schema FKs are forbidden platform-wide, and one here would let user
* deletion cascade away the record of what that user did.
*
* DDL is idempotent (`IF NOT EXISTS`) because watch-mode API instances race
* `migrationsRun` against each other on the shared dev database.
*/
export class CreateAuditLogs3390000000000 implements MigrationInterface {
name = "CreateAuditLogs3390000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.audit_logs (
id uuid NOT NULL DEFAULT gen_random_uuid(),
title varchar(255) NOT NULL,
method varchar(10) NOT NULL,
url text NOT NULL,
route_path varchar(255),
type varchar(50) NOT NULL,
is_success boolean NOT NULL,
user_id uuid,
resource_id varchar(64),
request jsonb,
status_code smallint,
error_message text,
user_name varchar(150),
user_role varchar(100),
ip_address inet,
user_agent text,
request_id varchar(64),
duration_ms integer,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT "PK_audit_logs" PRIMARY KEY (id)
)
`);
// Every audit query is time-bounded, so created_at leads each index.
// DESC matches the "newest first" read path the controller exposes.
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_audit_logs_created_at"
ON freight.audit_logs (created_at DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_audit_logs_user_id_created_at"
ON freight.audit_logs (user_id, created_at DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_audit_logs_type_created_at"
ON freight.audit_logs (type, created_at DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_audit_logs_type_resource_id"
ON freight.audit_logs (type, resource_id)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_audit_logs_route_path_created_at"
ON freight.audit_logs (route_path, created_at DESC)
`);
// Failures are a small slice of the table but carry the security signal
// (403s especially), so they get their own partial index.
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_audit_logs_failures"
ON freight.audit_logs (created_at DESC)
WHERE is_success = false
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.audit_logs`);
}
}

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Wagons the requester specifically asked for. A transfer request stays
* count-driven (`quantity` is what must be delivered), but a requester who
* picked wagons off the yard desk now records WHICH ones — OCC sees the numbers
* on the queue and the fulfil picker pre-selects them.
*
* Stored as a uuid[] column rather than a join table: the list is read and
* written whole, never queried by wagon, and a preference carries no lifecycle
* of its own (no FK — a purged wagon simply drops out of the display).
*/
export class TransferRequestPreferredWagons3400000000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagon_transfer_requests
ADD COLUMN IF NOT EXISTS preferred_wagon_ids uuid[]
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagon_transfer_requests
DROP COLUMN IF EXISTS preferred_wagon_ids
`);
}
}

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Voyage number for one departure.
*
* `train_number` already exists on a schedule (the run number), but operations
* also quote a VOYAGE number — the sailing/run identifier yards and customs use
* for a specific departure. It belongs on the schedule, not the built train: one
* train serves many departures and each carries its own voyage.
*
* Nullable and un-indexed: it is display/reference data typed by staff, not a
* lookup key, and older schedules simply have none.
*/
export class ScheduleVoyageNumber3410000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS voyage_number varchar(20)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
DROP COLUMN IF EXISTS voyage_number
`);
}
}

View File

@@ -0,0 +1,84 @@
/**
* Who acted, and does this API audit them?
*
* The staff/customer split reuses the exact discriminator the permission guards
* already apply (`freight-permission.guard.ts`): `userType === 'employee'` is
* backoffice, `individual` / `external_organization` are customers. Restating
* the rule instead of importing it would let the two drift apart silently.
*/
const EMPLOYEE_USER_TYPE = 'employee';
const SUPER_ADMIN_ROLE = 'super_admin';
/** The subset of the JWT payload this module reads. */
export interface AuditActorSource {
id?: string;
sub?: string;
userType?: string;
username?: string;
name?: string | { en?: string; am?: string };
firstName?: string;
lastName?: string;
email?: string;
roles?: { key?: string; name?: string }[];
employee?: unknown;
}
export interface AuditActor {
userId: string | null;
userName: string | null;
userRole: string | null;
}
function isSuperAdmin(user: AuditActorSource): boolean {
return Boolean(user.roles?.some((role) => role.key === SUPER_ADMIN_ROLE));
}
/**
* Is this caller a backoffice user whose actions are audited?
*
* Only employees qualify. Customers are excluded by request, and unauthenticated
* callers are excluded too — which means failed logins, OTP sends and password
* resets produce no audit rows. That was a deliberate call: those endpoints are
* not backoffice actions. Note the trade-off, since failed-auth attempts are
* often what an incident review looks for first.
*/
export function isAuditableActor(user: AuditActorSource | null | undefined): boolean {
if (!user) return false;
// Super admins may not carry an `employee` userType on every token, but are
// unambiguously staff — the permission guards treat them the same way.
return user.userType === EMPLOYEE_USER_TYPE || isSuperAdmin(user);
}
/** Best-effort display name, tolerating the several shapes tokens use. */
function resolveUserName(user: AuditActorSource): string | null {
if (typeof user.name === 'string' && user.name.trim()) return user.name.trim();
if (user.name && typeof user.name === 'object') {
const localized = user.name.en ?? user.name.am;
if (localized?.trim()) return localized.trim();
}
const composed = [user.firstName, user.lastName].filter(Boolean).join(' ').trim();
if (composed) return composed;
return user.username?.trim() || user.email?.trim() || null;
}
/**
* Snapshot the actor at the moment of the action.
*
* Name and role are copied, never referenced: resolving them from IAM at read
* time would rewrite history whenever someone is renamed, changes role or is
* deleted. An audit row from last year must still say who acted and with what
* authority *then*.
*/
export function resolveAuditActor(user: AuditActorSource): AuditActor {
const roleKey = user.roles?.[0]?.key ?? user.roles?.[0]?.name ?? null;
return {
userId: user.id ?? user.sub ?? null,
userName: resolveUserName(user),
userRole: roleKey,
};
}

View File

@@ -0,0 +1,140 @@
import { AUDIT_ENDPOINTS, type AuditEndpointMeta } from './audit-endpoints';
/** What a matched request resolved to. */
export interface MatchedAuditEndpoint {
/** Human-readable action, e.g. "Approve contract". */
title: string;
/** Primary entity, e.g. "Contract". */
type: string;
/** The route template, e.g. `/api/contracts/:id/cancel`. */
routePath: string;
/** First path parameter of the template, when the route has one. */
resourceId: string | null;
}
interface CompiledRoute {
regex: RegExp;
/** Param names in capture-group order, e.g. ['id', 'stepId']. */
paramNames: string[];
routePath: string;
meta: AuditEndpointMeta;
/** Literal (non-parameter) segment count — used to rank specificity. */
staticSegments: number;
}
const ESCAPE_REGEX = /[.*+?^${}()|[\]\\]/g;
/**
* Two keys in AUDIT_ENDPOINTS point at the same path: one route is declared by
* two different controllers, so the generator suffixed the second with
* ` [modules/...controller.ts]` to keep both entries. Only the path itself is
* matchable, so the suffix is stripped here.
*/
function stripSourceSuffix(key: string): string {
const bracket = key.indexOf(' [');
return bracket === -1 ? key : key.slice(0, bracket);
}
/**
* Compile one `"/api/contracts/:id/cancel"` template into an anchored regex.
*
* A parameter matches a single path segment only (`[^/]+`), so
* `/api/contracts/:id` cannot swallow `/api/contracts/:id/cancel`.
*/
function compileTemplate(path: string): { regex: RegExp; paramNames: string[] } {
const paramNames: string[] = [];
const pattern = path
.split('/')
.map((segment) => {
if (!segment.startsWith(':')) {
return segment.replace(ESCAPE_REGEX, '\\$&');
}
paramNames.push(segment.slice(1));
return '([^/]+)';
})
.join('/');
return { regex: new RegExp(`^${pattern}$`), paramNames };
}
/**
* Method-bucketed lookup table for the audited routes.
*
* A direct `AUDIT_ENDPOINTS[url]` lookup cannot work: the keys are templates
* with `:params` while a live request carries real ids and a query string, so
* every parameterized route — most of the 488 — would miss. Templates are
* compiled to regexes once at module load and matched per request.
*
* Within a method, routes are ordered by literal-segment count descending, so
* a specific route always wins over a parameterized one that could also match
* (`/api/routes/:id/permanent` before `/api/routes/:id`).
*/
class AuditEndpointMatcher {
private readonly byMethod = new Map<string, CompiledRoute[]>();
constructor() {
for (const [key, meta] of Object.entries(AUDIT_ENDPOINTS)) {
const [method, rawPath] = stripSourceSuffix(key).split(' ');
if (!method || !rawPath) continue;
const { regex, paramNames } = compileTemplate(rawPath);
const bucket = this.byMethod.get(method) ?? [];
bucket.push({
regex,
paramNames,
routePath: rawPath,
meta,
staticSegments: rawPath
.split('/')
.filter((s) => s && !s.startsWith(':')).length,
});
this.byMethod.set(method, bucket);
}
for (const bucket of this.byMethod.values()) {
bucket.sort((a, b) => b.staticSegments - a.staticSegments);
}
}
/**
* Resolve a live request to its audit metadata, or null when the route is
* not audited (every GET, and anything absent from AUDIT_ENDPOINTS).
*
* `url` may include a query string; it is ignored for matching.
*/
match(method: string, url: string): MatchedAuditEndpoint | null {
const bucket = this.byMethod.get(method.toUpperCase());
if (!bucket) return null;
const path = stripQuery(url);
for (const route of bucket) {
const result = route.regex.exec(path);
if (!result) continue;
const [title, , type] = route.meta;
return {
title,
type,
routePath: route.routePath,
// The first path parameter is the affected record in this API's
// conventions (`/api/contracts/:id/...`). Routes with no parameter
// (a create) legitimately have no resource id yet.
resourceId: route.paramNames.length > 0 ? result[1] : null,
};
}
return null;
}
}
/** Strip query string and hash from a URL, leaving the path. */
export function stripQuery(url: string): string {
const queryIndex = url.indexOf('?');
const path = queryIndex === -1 ? url : url.slice(0, queryIndex);
const hashIndex = path.indexOf('#');
return hashIndex === -1 ? path : path.slice(0, hashIndex);
}
/** Compiled once at module load and shared by the interceptor. */
export const auditEndpointMatcher = new AuditEndpointMatcher();

View File

@@ -0,0 +1,641 @@
/**
* Freight API — every state-changing endpoint (POST / PUT / PATCH / DELETE).
*
* Shape: "<METHOD> <path>": [title, method, entity]
*
* Keyed by method + path rather than path alone: 50 paths serve more than one
* method (PATCH and DELETE on /api/contracts/:id, for example), so a path-only
* key would collide and drop those endpoints.
*
* Paths include the global prefix `api` (see app.setGlobalPrefix in src/main.ts).
* Titles come from each route's @ApiOperation summary, falling back to a
* humanized handler name where a route has none.
*
* Excludes the AI Assist and Account entities.
* Generated from the controllers under src/ — 488 endpoints.
*/
/** [title, method, entity] for one auditable route. */
export type AuditEndpointMeta = readonly [title: string, method: string, entity: string];
export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
// Approval Rule
"POST /api/approval-rules": ["Create an approval rule step", "POST", "Approval Rule"],
"PATCH /api/approval-rules/:id": ["Update an approval rule", "PATCH", "Approval Rule"],
"DELETE /api/approval-rules/:id": ["Soft-delete an approval rule", "DELETE", "Approval Rule"],
"POST /api/approval-rules/:id/move-order": ["Move an approval step up or down within its chain", "POST", "Approval Rule"],
"POST /api/approval-rules/reorder": ["Bulk reorder approval steps within a chain", "POST", "Approval Rule"],
// Booking
"POST /api/bookings": ["Create a new freight booking (DRAFT)", "POST", "Booking"],
"POST /api/bookings/:bookingId/allocate-containers": ["Allocate containers to vehicles", "POST", "Booking"],
"PATCH /api/bookings/:id": ["Update booking", "PATCH", "Booking"],
"DELETE /api/bookings/:id": ["Soft-delete DRAFT booking", "DELETE", "Booking"],
"POST /api/bookings/:id/cancel": ["Cancel booking", "POST", "Booking"],
"POST /api/bookings/:id/cancel-hold": ["Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED);", "POST", "Booking"],
"POST /api/bookings/:id/clearance/declaration": ["GL ET uploads customs declaration on booking (GENERAL customs)", "POST", "Booking"],
"POST /api/bookings/:id/clearance/delivery-order": ["Upload Booking Delivery Order", "POST", "Booking"],
"POST /api/bookings/:id/clearance/documents": ["Customer uploads clearance documents (fieldname = document key)", "POST", "Booking"],
"POST /api/bookings/:id/clearance/draft-declaration": ["GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review", "POST", "Booking"],
"POST /api/bookings/:id/clearance/draft-declaration/accept": ["Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia", "POST", "Booking"],
"POST /api/bookings/:id/clearance/draft-declaration/change": ["Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)", "POST", "Booking"],
"POST /api/bookings/:id/clearance/duty": ["GL ET sets duty/tax on booking with notice attachment", "POST", "Booking"],
"POST /api/bookings/:id/clearance/duty-slip": ["Customer uploads duty/tax payment slip on booking", "POST", "Booking"],
"POST /api/bookings/:id/clearance/export-release": ["Confirm Booking Export Release", "POST", "Booking"],
"POST /api/bookings/:id/clearance/finalize": ["GL finalizes clearance (requires 100% approved) → CLEARANCE_READY", "POST", "Booking"],
"POST /api/bookings/:id/clearance/finalize-pre-clearance": ["GL ET finalizes import pre-clearance on booking", "POST", "Booking"],
"POST /api/bookings/:id/clearance/output-documents": ["GL uploads customs output documents (IM4/EX3/…)", "POST", "Booking"],
"POST /api/bookings/:id/clearance/proceed": ["Customer requests operation with a schedule day", "POST", "Booking"],
"POST /api/bookings/:id/clearance/release-order": ["Upload Booking Release Order", "POST", "Booking"],
"POST /api/bookings/:id/clearance/review": ["GL reviews a clearance document (Approve | Query)", "POST", "Booking"],
"POST /api/bookings/:id/clearance/ro-amendment": ["Request Booking RO Amendment", "POST", "Booking"],
"POST /api/bookings/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Booking"],
"POST /api/bookings/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration", "POST", "Booking"],
"POST /api/bookings/:id/clearance/transit-permit": ["Upload Booking Transit Permit", "POST", "Booking"],
"POST /api/bookings/:id/confirm-submit": ["Confirm submit after price change", "POST", "Booking"],
"POST /api/bookings/:id/consolidation": ["Request freight consolidation", "POST", "Booking"],
"DELETE /api/bookings/:id/consolidation": ["Remove consolidation pairing", "DELETE", "Booking"],
"POST /api/bookings/:id/contract/generate": ["Generate contract PDF from template", "POST", "Booking"],
"POST /api/bookings/:id/contract/sign": ["Apply digital signature (customer or staff)", "POST", "Booking"],
"POST /api/bookings/:id/customer-cancel": ["Customer cancels their own booking before payment — no cancellation fee", "POST", "Booking"],
"POST /api/bookings/:id/customer-truck-assignment": ["Customer assigns external truck and driver for terminal pickup", "POST", "Booking"],
"POST /api/bookings/:id/customer-trucks": ["Add a customer self-haul truck carrying 12 of the booking containers", "POST", "Booking"],
"PATCH /api/bookings/:id/customer-trucks/:assignmentId": ["Edit a not-yet-arrived customer truck (plate/driver/type + containers)", "PATCH", "Booking"],
"DELETE /api/bookings/:id/customer-trucks/:assignmentId": ["Remove a not-yet-arrived customer truck from a booking", "DELETE", "Booking"],
"POST /api/bookings/:id/customer-trucks/:assignmentId/depart": ["Register an import truck leaving: containers loaded + weighed gross (staff)", "POST", "Booking"],
"POST /api/bookings/:id/customer-trucks/:assignmentId/load": ["Truck_dispatch: load selected containers onto a truck (staff)", "POST", "Booking"],
"POST /api/bookings/:id/customer-trucks/bulk": ["Bulk add customer trucks from array payload (Excel parsed)", "POST", "Booking"],
"POST /api/bookings/:id/customer/sign": ["Customer digital signature (deprecated — use POST contract/sign)", "POST", "Booking"],
"POST /api/bookings/:id/documents": ["Upload documents for a booking (DRAFT only)", "POST", "Booking"],
"PATCH /api/bookings/:id/export-handover-mode": ["Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first", "PATCH", "Booking"],
"POST /api/bookings/:id/generate-grn": ["Generate a GRN over the received containers (all received, or a subset) — one GRN per batch", "POST", "Booking"],
"POST /api/bookings/:id/generate-price": ["Generate price preview (DRAFT or CHANGES_REQUESTED)", "POST", "Booking"],
"POST /api/bookings/:id/government-expedite": ["Expedite government booking to PAID / ELIGIBLE for scheduling", "POST", "Booking"],
"POST /api/bookings/:id/marketing/approve": ["Staff contract signature and fully execute (use contract/sign STAFF preferred)", "POST", "Booking"],
"POST /api/bookings/:id/operation/review": ["Operations reviews an operation request: ACCEPT (→ batch pool),", "POST", "Booking"],
"POST /api/bookings/:id/operations/complete": ["Mark completed", "POST", "Booking"],
"POST /api/bookings/:id/operations/start-transit": ["Mark in transit", "POST", "Booking"],
"POST /api/bookings/:id/reject": ["Customer reject price estimate", "POST", "Booking"],
"POST /api/bookings/:id/staff/accept": ["Staff accept intake → set contract validity window + start approval chain", "POST", "Booking"],
"POST /api/bookings/:id/staff/reject": ["Staff final reject", "POST", "Booking"],
"POST /api/bookings/:id/staff/request-changes": ["Staff return booking for customer updates", "POST", "Booking"],
"POST /api/bookings/:id/submit": ["Customer submit booking", "POST", "Booking"],
"POST /api/bookings/:id/wagon-cancellations": ["Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles", "POST", "Booking"],
"POST /api/bookings/:id/wagon-cancellations/preview": ["Preview the fee/credit of a partial wagon cancellation (no writes)", "POST", "Booking"],
"POST /api/bookings/wagon-cancellations/:cancellationId/rebook": ["Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)", "POST", "Booking"],
"POST /api/bookings/wagon-cancellations/:cancellationId/withdraw": ["Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)", "POST", "Booking"],
// Cargo
"POST /api/cargoes": ["Create a new cargo", "POST", "Cargo"],
"PATCH /api/cargoes/:id": ["Update a cargo", "PATCH", "Cargo"],
"DELETE /api/cargoes/:id": ["Delete a cargo", "DELETE", "Cargo"],
"POST /api/cargoes/:id/deliver": ["Mark cargo as delivered", "POST", "Cargo"],
"POST /api/cargoes/:id/load": ["Load cargo into a container", "POST", "Cargo"],
"POST /api/cargoes/:id/unload": ["Unload cargo from container", "POST", "Cargo"],
// Cargo Type
"POST /api/cargo-types": ["Create a cargo type", "POST", "Cargo Type"],
"PATCH /api/cargo-types/:id": ["Update a cargo type", "PATCH", "Cargo Type"],
"DELETE /api/cargo-types/:id": ["Soft-delete a cargo type", "DELETE", "Cargo Type"],
"POST /api/cargo-types/:id/move-order": ["Move a cargo type up or down in display order", "POST", "Cargo Type"],
"POST /api/cargo-types/reorder": ["Bulk reorder cargo types by ID list", "POST", "Cargo Type"],
// Company
"POST /api/companies": ["Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)", "POST", "Company"],
"POST /api/companies/:companyId/documents": ["Upload documents for a company (onboarding)", "POST", "Company"],
"POST /api/companies/:companyId/profiles": ["Add a profile (employee) to a company", "POST", "Company"],
"PATCH /api/companies/:id": ["Update a company", "PATCH", "Company"],
"DELETE /api/companies/:id": ["Soft-delete a company", "DELETE", "Company"],
"POST /api/companies/change-requests/:id/approve": ["Approve a pending profile change request (applies the changes)", "POST", "Company"],
"POST /api/companies/change-requests/:id/reject": ["Reject a pending profile change request with a note", "POST", "Company"],
"POST /api/companies/change-requests/:id/request-changes": ["Ask for specific changes on a pending request without rejecting it (row stays open, next edit appends to it)", "POST", "Company"],
"POST /api/companies/company-profile": ["Create a single operational profile for the current user's company. The role starts pending and does not become the active mode", "POST", "Company"],
"POST /api/companies/company-profiles": ["Add operational profile(s) (importer/exporter/forwarder) to the current user's company", "POST", "Company"],
"POST /api/companies/company-profiles/:profileId/license": ["Add business-license document(s) to a profile. For an approved company", "POST", "Company"],
"DELETE /api/companies/company-profiles/:profileId/license/:fileId": ["Remove a business-license file (staged for review on an approved company)", "DELETE", "Company"],
"POST /api/companies/company-profiles/:profileId/license/:fileId/replace": ["Replace a business-license file with a newly uploaded one (staged for", "POST", "Company"],
"POST /api/companies/company-profiles/:profileId/reapply": ["Resubmit a rejected operational role for approval (→ pending)", "POST", "Company"],
"PATCH /api/companies/company-profiles/:profileId/status": ["Update a company profile's approval status", "PATCH", "Company"],
"POST /api/companies/create": ["Create a company with its associated external profile (onboarding)", "POST", "Company"],
"POST /api/companies/documents/:fileId/request-change": ["Ask the customer to correct one uploaded document", "POST", "Company"],
"POST /api/companies/fetch-etrade-info": ["Fetch company info from eTrade by TIN", "POST", "Company"],
"POST /api/companies/identity/fayda/complete": ["Bind a completed Fayda verification to the company's owner or Power of Attorney", "POST", "Company"],
"DELETE /api/companies/identity/fayda/poa": ["Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together", "DELETE", "Company"],
"DELETE /api/companies/identity/gm": ["Clear the General Manager's identity — the \\\"same as owner\\\" declaration or a verification, and the details either wrote", "DELETE", "Company"],
"POST /api/companies/identity/gm/same-as-owner": ["Declare the General Manager is the company's owner, copying the owner's verified identity across", "POST", "Company"],
"POST /api/companies/identity/poa/same-as-owner": ["Declare the Power of Attorney is the company's owner, copying the owner's identity across", "POST", "Company"],
"DELETE /api/companies/identity/poa/same-as-owner": ["Undo the Power of Attorney \\\"same as owner\\\" declaration and the identity it copied, leaving the representative open to be verified in their own right", "DELETE", "Company"],
"PATCH /api/companies/onboarding-step": ["Persist the user's current onboarding wizard step", "PATCH", "Company"],
"POST /api/companies/onboarding/complete": ["Mark the current user's onboarding as complete", "POST", "Company"],
"POST /api/companies/onboarding/start": ["Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally", "POST", "Company"],
"POST /api/companies/poa-delegation": ["Upload the Power of Attorney delegation letter, replacing any existing one", "POST", "Company"],
"DELETE /api/companies/poa-delegation/:fileId": ["Remove the Power of Attorney delegation letter (staged for review on an approved company)", "DELETE", "Company"],
"PATCH /api/companies/profile": ["Update profile (flattened settings page)", "PATCH", "Company"],
// Compliance
"POST /api/compliance": ["Create a compliance record", "POST", "Compliance"],
"PATCH /api/compliance/:id": ["Update a compliance record", "PATCH", "Compliance"],
"DELETE /api/compliance/:id": ["Soft-delete a compliance record", "DELETE", "Compliance"],
// Consignment
"POST /api/consignments": ["Create a new consignment", "POST", "Consignment"],
// Container
"POST /api/containers": ["Create a new container", "POST", "Container"],
"PATCH /api/containers/:id": ["Update a container", "PATCH", "Container"],
"DELETE /api/containers/:id": ["Delete a container", "DELETE", "Container"],
"POST /api/containers/:id/assign-wagon": ["Assign container to a wagon", "POST", "Container"],
"POST /api/containers/:id/unassign-wagon": ["Unassign container from wagon", "POST", "Container"],
// Container Type
"POST /api/container-types": ["Create a container type", "POST", "Container Type"],
"PATCH /api/container-types/:id": ["Update a container type", "PATCH", "Container Type"],
"DELETE /api/container-types/:id": ["Soft-delete a container type", "DELETE", "Container Type"],
"POST /api/container-types/:id/move-order": ["Move a container type up or down in display order", "POST", "Container Type"],
"POST /api/container-types/reorder": ["Bulk reorder container types by ID list", "POST", "Container Type"],
// Contract
"POST /api/contracts": ["Create a new contract (DRAFT) with routes + cargo scope", "POST", "Contract"],
"PATCH /api/contracts/:id": ["Update contract", "PATCH", "Contract"],
"DELETE /api/contracts/:id": ["Soft-delete DRAFT contract", "DELETE", "Contract"],
"POST /api/contracts/:id/approval-steps/:stepId/approve": ["Approve one approval step in sequence", "POST", "Contract"],
"POST /api/contracts/:id/approval-steps/:stepId/reject": ["Reject one approval step — to the customer (terminal → REJECTED) or, via returnToStepId, back to an earlier approver (chain re-runs from there)", "POST", "Contract"],
"POST /api/contracts/:id/booking-requests": ["Customer submits a shipment request on a GENERAL customs contract", "POST", "Contract"],
"POST /api/contracts/:id/bookings": ["Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia)", "POST", "Contract"],
"POST /api/contracts/:id/bookings/:bookingId/complete": ["Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing", "POST", "Contract"],
"POST /api/contracts/:id/bookings/initiate": ["Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request", "POST", "Contract"],
"POST /api/contracts/:id/cancel": ["Customer cancels their own contract (blocked while a booking is live)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/declaration": ["GL ET uploads customs declaration documents (multi-file)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/delivery-order": ["GL DJ uploads Delivery Order (import) with vessel arrival + DO collected dates", "POST", "Contract"],
"POST /api/contracts/:id/clearance/documents": ["Customer uploads clearance documents (fieldname = document key)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/documents/:fileKey/replace": ["GL replaces a clearance document in place (reason required) — the previous version is kept in the file history and the new one needs approving", "POST", "Contract"],
"POST /api/contracts/:id/clearance/duty": ["GL ET sets duty/tax requirement and advises amount with notice attachment", "POST", "Contract"],
"POST /api/contracts/:id/clearance/duty-slip": ["Customer uploads duty/tax payment slip on contract", "POST", "Contract"],
"POST /api/contracts/:id/clearance/duty/dispute": ["Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/export-release": ["GL ET confirms export release after declaration", "POST", "Contract"],
"POST /api/contracts/:id/clearance/finalize": ["GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING (legacy)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/finalize-export-clearance": ["GL ET finalizes export clearance after post-booking transit permit upload", "POST", "Contract"],
"POST /api/contracts/:id/clearance/finalize-pre-clearance": ["GL ET finalizes import pre-clearance — unlocks Djibouti DO upload", "POST", "Contract"],
"POST /api/contracts/:id/clearance/ops-finalize": ["Operations finalizes self-clearance → customer may create the booking", "POST", "Contract"],
"POST /api/contracts/:id/clearance/ops-review": ["Operations reviews a customer self-clearance document (Approve | Query)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/output-documents": ["GL uploads customs output documents (IM4/EX3/…) pre-booking", "POST", "Contract"],
"POST /api/contracts/:id/clearance/release-order": ["GL DJ uploads Release Order + vessel departure date (export)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/review": ["GL ET reviews a clearance document (Approve | Query)", "POST", "Contract"],
"POST /api/contracts/:id/clearance/ro-amendment": ["GL DJ requests port amendment when RO vessel window is too short", "POST", "Contract"],
"POST /api/contracts/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Contract"],
"POST /api/contracts/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the customs declaration", "POST", "Contract"],
"POST /api/contracts/:id/clearance/transit-permit": ["GL ET uploads import transit permit documents (multi-file)", "POST", "Contract"],
"POST /api/contracts/:id/confirm-submit": ["Confirm submit after a price change", "POST", "Contract"],
"POST /api/contracts/:id/contract/generate": ["Generate contract document → CONTRACT_READY", "POST", "Contract"],
"POST /api/contracts/:id/contract/send-signing-otp": ["Send the sudo-mode signing OTP to the contract company's registered phone (server picks the number)", "POST", "Contract"],
"POST /api/contracts/:id/contract/sign": ["Apply digital signature (customer or staff/director/ceo)", "POST", "Contract"],
"PUT /api/contracts/:id/document/articles": ["Edit this contract\\'s document articles only (per-contract; never touches the six shared templates)", "PUT", "Contract"],
"POST /api/contracts/:id/documents": ["Upload intake documents for a contract (DRAFT only)", "POST", "Contract"],
"POST /api/contracts/:id/generate-price": ["Generate unit-rate breakdown (no totals at contract phase)", "POST", "Contract"],
"POST /api/contracts/:id/milestones/:code/complete": ["GL marks a pre-booking (contract) milestone complete", "POST", "Contract"],
"POST /api/contracts/:id/renew": ["Create a renewal draft linked via renewalOfId", "POST", "Contract"],
"POST /api/contracts/:id/resume": ["Staff lift a suspension — contract returns to its prior status", "POST", "Contract"],
"POST /api/contracts/:id/staff/accept": ["Staff accept → set validity window + start approval chain", "POST", "Contract"],
"POST /api/contracts/:id/staff/reject": ["Staff reject contract", "POST", "Contract"],
"POST /api/contracts/:id/staff/request-changes": ["Staff return contract for customer updates", "POST", "Contract"],
"POST /api/contracts/:id/submit": ["Customer submit contract (freezes contract_rate_snapshots)", "POST", "Contract"],
"POST /api/contracts/:id/suspend": ["Staff freeze a signed contract (reversible, any post-signature step)", "POST", "Contract"],
"POST /api/contracts/:id/validate-shipment": ["Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created)", "POST", "Contract"],
"POST /api/contracts/booking-requests/:reqId/accept": ["GL marks a shipment request accepted + links the created booking", "POST", "Contract"],
"POST /api/contracts/booking-requests/:reqId/cancel": ["Customer cancels their own pending shipment request", "POST", "Contract"],
"POST /api/contracts/booking-requests/:reqId/reject": ["GL rejects a shipment request", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/documents": ["GL uploads post-booking operational documents (DO/RO/T1/…)", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/duty": ["GL ET advises duty & tax amount + declaration serial", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/duty-slip": ["Customer uploads the duty/tax payment slip", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/final-invoice": ["GL DJ raises the post-offload final invoice (amount + invoice document)", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/final-invoice-slip": ["Customer attaches the payment slip for the final invoice", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/final-invoice/approve": ["Customer approves the drafted final invoice — unlocks the payment slip", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/final-invoice/confirm": ["GL (ET or DJ) confirms the payment slip — settles the final invoice", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/incidents": ["GL DJ logs a cargo exception with photo evidence", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/milestones/:code/complete": ["GL / Ops / Terminal marks a post-booking milestone complete", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/risk": ["GL ET assigns a customs risk level (GREEN/YELLOW/RED)", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/second-duty": ["GL ET advises (or skips) the post-arrival additional duty/tax round (import)", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/second-duty-slip": ["Customer attaches the additional duty/tax payment slip", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/station-assign": ["GL station manager routes the shipment + binds staff", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/t1-close": ["Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export)", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/t1-documents": ["GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs", "POST", "Contract"],
"POST /api/contracts/bookings/:bookingId/transport-document": ["GL ET uploads export transit permit documents (multi-file)", "POST", "Contract"],
"POST /api/gl-exchange/:entityId": ["Share a document with the other GL desk", "POST", "Contract"],
"PATCH /api/gl-exchange/documents/:documentId": ["Uploader edits a shared document (title, visibility, file)", "PATCH", "Contract"],
"DELETE /api/gl-exchange/documents/:documentId": ["Uploader removes a shared document", "DELETE", "Contract"],
// Contract Template
"POST /api/contract-templates": ["Create a bulk contract template for a (cargo type, customs option) pair", "POST", "Contract Template"],
"PATCH /api/contract-templates/:code": ["Update template metadata (name, title, recitals, active flag)", "PATCH", "Contract Template"],
"DELETE /api/contract-templates/:code": ["Delete a staff-created bulk template (system templates refuse)", "DELETE", "Contract Template"],
"POST /api/contract-templates/:code/articles": ["Add an article to the template", "POST", "Contract Template"],
"PUT /api/contract-templates/:code/articles": ["Replace the full ordered article list (used for reorder)", "PUT", "Contract Template"],
"PATCH /api/contract-templates/:code/articles/:articleId": ["Update an article's title or body", "PATCH", "Contract Template"],
"DELETE /api/contract-templates/:code/articles/:articleId": ["Remove an article from the template", "DELETE", "Contract Template"],
"POST /api/contract-templates/:code/preview": ["Render an HTML preview of the template against mock contract data", "POST", "Contract Template"],
// Driver
"POST /api/drivers": ["Create a new driver", "POST", "Driver"],
"PATCH /api/drivers/:id": ["Update a driver", "PATCH", "Driver"],
"DELETE /api/drivers/:id": ["Delete a driver", "DELETE", "Driver"],
"POST /api/drivers/:id/documents": ["Upload driver documents (code driver_docs)", "POST", "Driver"],
"DELETE /api/drivers/:id/documents/:fileId": ["Delete a driver document", "DELETE", "Driver"],
// Dropdown Setting
"POST /api/dropdown-settings": ["Create a new dropdown setting", "POST", "Dropdown Setting"],
"PATCH /api/dropdown-settings/:id": ["Update a dropdown setting's metadata", "PATCH", "Dropdown Setting"],
"DELETE /api/dropdown-settings/:id": ["Soft-delete a dropdown setting", "DELETE", "Dropdown Setting"],
"POST /api/dropdown-settings/:id/options": ["Append a single option to a setting", "POST", "Dropdown Setting"],
"PUT /api/dropdown-settings/:id/options": ["Replace the full option list for a setting", "PUT", "Dropdown Setting"],
"PATCH /api/dropdown-settings/options/:optionId": ["Update a single option", "PATCH", "Dropdown Setting"],
"DELETE /api/dropdown-settings/options/:optionId": ["Soft-delete a single option", "DELETE", "Dropdown Setting"],
// EIMS Invoice
"POST /api/invoices/:id/eims/register": ["Register the invoice with MoR EIMS. Idempotent — an invoice that already has an IRN is returned unchanged", "POST", "EIMS Invoice"],
"POST /api/invoices/:id/eims/resolve": ["Resolve an unacknowledged submission: record the IRN confirmed with MoR, or discard it. Clears the system-wide block", "POST", "EIMS Invoice"],
"POST /api/invoices/:id/eims/verify": ["Verify the invoice's stored IRN against EIMS", "POST", "EIMS Invoice"],
// Exchange Setting
"PATCH /api/exchange-settings": ["Set the USD→ETB fallback by hand (used only while CBE is unreachable)", "PATCH", "Exchange Setting"],
// Facility
"POST /api/facilities": ["Create a new facility", "POST", "Facility"],
"PATCH /api/facilities/:id": ["Update a facility", "PATCH", "Facility"],
"DELETE /api/facilities/:id": ["Delete a facility (soft delete)", "DELETE", "Facility"],
// Fayda Verification
"POST /api/fayda/verification/start": ["Start a VeriFayda 2.0 verification session", "POST", "Fayda Verification"],
// File Upload Setting
"POST /api/file-upload-settings": ["Create a new file upload setting", "POST", "File Upload Setting"],
"PATCH /api/file-upload-settings/:id": ["Update a file upload setting's metadata", "PATCH", "File Upload Setting"],
"DELETE /api/file-upload-settings/:id": ["Soft-delete a file upload setting", "DELETE", "File Upload Setting"],
"POST /api/file-upload-settings/:id/fields": ["Append a single field to a setting", "POST", "File Upload Setting"],
"PUT /api/file-upload-settings/:id/fields": ["Replace the full field list for a setting", "PUT", "File Upload Setting"],
"PATCH /api/file-upload-settings/fields/:fieldId": ["Update a single field", "PATCH", "File Upload Setting"],
"DELETE /api/file-upload-settings/fields/:fieldId": ["Soft-delete a single field", "DELETE", "File Upload Setting"],
// First Mile
"POST /api/first-mile": ["Create a first-mile leg", "POST", "First Mile"],
"PATCH /api/first-mile/:id": ["Update a first-mile leg", "PATCH", "First Mile"],
"DELETE /api/first-mile/:id": ["Soft-delete a first-mile leg", "DELETE", "First Mile"],
"POST /api/first-mile/:id/distances": ["Set per-vehicle actual distances (does not generate an invoice)", "POST", "First Mile"],
"POST /api/first-mile/:id/invoice": ["Generate the first-mile delivery-fee invoice", "POST", "First Mile"],
"POST /api/first-mile/:id/vehicles": ["Set the vehicles assigned to a first-mile pickup (multi-truck)", "POST", "First Mile"],
"POST /api/first-mile/accept/:reference": ["Accept a paid booking and create a first-mile leg", "POST", "First Mile"],
// Fuel
"POST /api/fuel/purchases": ["Record fuel purchase", "POST", "Fuel"],
// GPS Tracking
"POST /api/gps/devices": ["Register a GPS tracker", "POST", "GPS Tracking"],
"PATCH /api/gps/devices/:id": ["Update a GPS tracker (name / assigned vehicle)", "PATCH", "GPS Tracking"],
"DELETE /api/gps/devices/:id": ["Delete a GPS tracker", "DELETE", "GPS Tracking"],
// Import Operation
"POST /api/import-operations/customs/:bookingId/declaration": ["Batch 12: record declaration serial number", "POST", "Import Operation"],
"POST /api/import-operations/customs/:bookingId/documents": ["Batch 12: upload IM4/IM5/T1/permit/payment-slip documents", "POST", "Import Operation"],
"POST /api/import-operations/customs/:bookingId/duties-taxes-paid": ["Batch 12: mark duties and taxes paid", "POST", "Import Operation"],
"POST /api/import-operations/customs/:bookingId/notify-duties-taxes": ["Batch 12: notify duties and taxes", "POST", "Import Operation"],
"POST /api/import-operations/customs/:bookingId/release-permitted": ["Batch 12: mark import release permitted", "POST", "Import Operation"],
"POST /api/import-operations/customs/:bookingId/risk": ["Batch 12: assign customs risk", "POST", "Import Operation"],
"POST /api/import-operations/djibouti-incidents": ["Batch 8: report a Djibouti import incident / exception", "POST", "Import Operation"],
"POST /api/import-operations/empty-container-returns": ["Batch 16: create an empty container return record", "POST", "Import Operation"],
"POST /api/import-operations/empty-container-returns/:id/status": ["Batch 16: advance empty container return workflow", "POST", "Import Operation"],
// Incident
"POST /api/incidents": ["Report an incident", "POST", "Incident"],
"PATCH /api/incidents/:id": ["Update an incident", "PATCH", "Incident"],
"DELETE /api/incidents/:id": ["Delete an incident", "DELETE", "Incident"],
// Interchange Document
"PATCH /api/interchange-documents/:id/acknowledge": ["Acknowledge an interchange document", "PATCH", "Interchange Document"],
"PATCH /api/interchange-documents/:id/dispute": ["Dispute an interchange document", "PATCH", "Interchange Document"],
"POST /api/interchange-documents/generate-from-schedule": ["Generate interchange document from a train schedule handover", "POST", "Interchange Document"],
// Last Mile
"POST /api/last-mile": ["Create a last-mile leg", "POST", "Last Mile"],
"PATCH /api/last-mile/:id": ["Update a last-mile leg", "PATCH", "Last Mile"],
"DELETE /api/last-mile/:id": ["Soft-delete a last-mile leg", "DELETE", "Last Mile"],
"POST /api/last-mile/:id/detention-times": ["Set each truck\\'s own detention window (arrived at destination / returned)", "POST", "Last Mile"],
"POST /api/last-mile/:id/distances": ["Set per-vehicle actual distances (does not generate an invoice)", "POST", "Last Mile"],
"POST /api/last-mile/:id/invoice": ["Generate the delivery-fee invoice for a last-mile leg", "POST", "Last Mile"],
"POST /api/last-mile/:id/proof-of-delivery": ["Record proof of delivery (signature + photos) and complete the leg", "POST", "Last Mile"],
"POST /api/last-mile/:id/vehicles": ["Set the vehicles assigned to a last-mile delivery (multi-truck)", "POST", "Last Mile"],
"POST /api/last-mile/:id/warehouse-gate-times": ["Set each truck\\'s warehouse gate arrival/departure times", "POST", "Last Mile"],
"POST /api/last-mile/accept/:reference": ["Accept a paid booking and create a last-mile leg", "POST", "Last Mile"],
// Last Mile Request
"POST /api/last-mile-requests/:id/approve": ["Truck & Machinery chief approves the request — the advance defaults to the live last-mile rate; LM contract becomes signable and the advance invoice follows the customer signature", "POST", "Last Mile Request"],
"POST /api/last-mile-requests/:id/contract/sign": ["Customer agrees and signs the LM contract — then the advance invoice is issued", "POST", "Last Mile Request"],
"POST /api/last-mile-requests/:id/reject": ["Truck & Machinery chief rejects the request with a reason", "POST", "Last Mile Request"],
"POST /api/last-mile-requests/:id/submit": ["Customer confirms which containers go via EDR last-mile", "POST", "Last Mile Request"],
// Locomotive
"POST /api/locomotives": ["Create a locomotive", "POST", "Locomotive"],
"PATCH /api/locomotives/:id": ["Update a locomotive", "PATCH", "Locomotive"],
"POST /api/locomotives/:id/decommission": ["Decommission a locomotive", "POST", "Locomotive"],
"DELETE /api/locomotives/:id/permanent": ["Permanently delete a locomotive (irreversible; refused if any train references it)", "DELETE", "Locomotive"],
// Maintenance
"POST /api/maintenance/costs": ["Record maintenance cost", "POST", "Maintenance"],
"POST /api/maintenance/intervals": ["Define/adjust a service interval (e.g. oil change every 10,000 km)", "POST", "Maintenance"],
"DELETE /api/maintenance/intervals/:id": ["Deactivate a service interval (stops auto-scheduling)", "DELETE", "Maintenance"],
"POST /api/maintenance/parts": ["Create part", "POST", "Maintenance"],
"PATCH /api/maintenance/parts/:id": ["Update part", "PATCH", "Maintenance"],
"DELETE /api/maintenance/parts/:id": ["Delete part", "DELETE", "Maintenance"],
"POST /api/maintenance/schedules": ["Schedule maintenance", "POST", "Maintenance"],
"PATCH /api/maintenance/schedules/:id": ["Update maintenance schedule", "PATCH", "Maintenance"],
"POST /api/maintenance/warranties": ["Create warranty", "POST", "Maintenance"],
"DELETE /api/maintenance/warranties/:id": ["Delete warranty", "DELETE", "Maintenance"],
"POST /api/maintenance/work-orders": ["Create work order", "POST", "Maintenance"],
"PATCH /api/maintenance/work-orders/:id": ["Update work order", "PATCH", "Maintenance"],
"DELETE /api/maintenance/work-orders/:id": ["Delete work order", "DELETE", "Maintenance"],
// Notification Inbox
"PATCH /api/notifications/:id/read": ["Mark one of my notifications as read", "PATCH", "Notification Inbox"],
"POST /api/notifications/read-all": ["Mark all my notifications as read", "POST", "Notification Inbox"],
// Organization User
"PUT /api/backoffice/organizations/:orgId/employee-users/:userId/roles": ["Replace org-scoped roles assigned to an employee user", "PUT", "Organization User"],
"POST /api/backoffice/organizations/:orgId/users": ["Create an organization user without assigning positions", "POST", "Organization User"],
// OTP
"POST /api/otp/send": ["Send OTP", "POST", "OTP"],
"POST /api/otp/verify": ["Verify OTP", "POST", "OTP"],
// Password Reset
"POST /api/auth/forgot-password/request": ["Send a password-reset code to the account's email AND phone", "POST", "Password Reset"],
"POST /api/auth/forgot-password/resolve-link": ["Validate a staff-issued reset link and return its set-password ticket", "POST", "Password Reset"],
"POST /api/auth/forgot-password/verify": ["Exchange a valid reset code for a single-use set-password ticket", "POST", "Password Reset"],
"POST /api/backoffice/customers/:companyId/reset-password": ["Send a password-reset link to a customer's primary contact", "POST", "Password Reset"],
// Payment
"POST /api/billing/invoices/:id/confirm-offline": ["Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance", "POST", "Payment"],
"POST /api/billing/my-invoices/:id/confirm": ["Confirm an OTP-debit payment (CAC Bank) for one of the customer's invoices", "POST", "Payment"],
"POST /api/billing/my-invoices/:id/pay": ["Initiate payment for one of the customer's invoices", "POST", "Payment"],
"POST /api/internal/payments/bill-query": ["Live still-payable check + payer name for a CBE bill (called while CBE is on the line)", "POST", "Payment"],
"POST /api/internal/payments/mark-paid": ["Apply a payment.succeeded / payment.failed event from the payment service (idempotent)", "POST", "Payment"],
"POST /api/payments/initiate": ["Initiate payment for an invoice", "POST", "Payment"],
"POST /api/payments/redirect-success/:bookingId": ["Success-redirect ack: mark payment processing + invoice PAYMENT_PROCESSING (webhook remains source of truth)", "POST", "Payment"],
// Priority Config
"POST /api/priority-configs": ["Create a priority config", "POST", "Priority Config"],
"PATCH /api/priority-configs/:id": ["Update a priority config", "PATCH", "Priority Config"],
"DELETE /api/priority-configs/:id": ["Soft-delete a priority config", "DELETE", "Priority Config"],
"POST /api/priority-configs/:id/move-order": ["Move a priority config up or down in display order", "POST", "Priority Config"],
"POST /api/priority-configs/reorder": ["Bulk reorder priority configs by ID list", "POST", "Priority Config"],
// Priority Rule Change Request
"POST /api/priority-rule-change-requests": ["Submit a priority-rule change for approval", "POST", "Priority Rule Change Request"],
"POST /api/priority-rule-change-requests/:id/approve": ["Approve and apply a pending change", "POST", "Priority Rule Change Request"],
"POST /api/priority-rule-change-requests/:id/reject": ["Reject a pending change", "POST", "Priority Rule Change Request"],
// Procurement
"POST /api/procurement/acquisitions": ["Create an asset acquisition", "POST", "Procurement"],
"PATCH /api/procurement/acquisitions/:id": ["Update an asset acquisition", "PATCH", "Procurement"],
"DELETE /api/procurement/acquisitions/:id": ["Delete an asset acquisition", "DELETE", "Procurement"],
"POST /api/procurement/disposals": ["Create an asset disposal", "POST", "Procurement"],
"DELETE /api/procurement/disposals/:id": ["Delete an asset disposal", "DELETE", "Procurement"],
"POST /api/procurement/vendors": ["Create a vendor", "POST", "Procurement"],
"PATCH /api/procurement/vendors/:id": ["Update a vendor", "PATCH", "Procurement"],
"DELETE /api/procurement/vendors/:id": ["Delete a vendor", "DELETE", "Procurement"],
// Rate
"POST /api/rates": ["Create a rate (DRAFT)", "POST", "Rate"],
"PATCH /api/rates/:id": ["Update a DRAFT rate", "PATCH", "Rate"],
"DELETE /api/rates/:id": ["Soft-delete a rate", "DELETE", "Rate"],
"POST /api/rates/:id/approve": ["CEO approves a rate", "POST", "Rate"],
"POST /api/rates/:id/submit": ["Submit rate for CEO approval", "POST", "Rate"],
// Rate Change Request
"POST /api/rate-change-requests": ["Propose a change to a LIVE rate", "POST", "Rate Change Request"],
"POST /api/rate-change-requests/:id/approve": ["Approve a rate change and put it into effect", "POST", "Rate Change Request"],
"POST /api/rate-change-requests/:id/reject": ["Reject a rate change — the rate keeps its current value", "POST", "Rate Change Request"],
// Route
"POST /api/routes": ["Create route", "POST", "Route"],
"PATCH /api/routes/:id": ["Update route", "PATCH", "Route"],
"DELETE /api/routes/:id": ["Deactivate route", "DELETE", "Route"],
"DELETE /api/routes/:id/permanent": ["Permanently delete a route (irreversible; refused while any train schedule references it)", "DELETE", "Route"],
// Schedule
// NOTE: duplicate route — also declared in modules/scheduling-reschedule/scheduling-reschedule.controller.ts:52.
// Two controllers register this same path; Nest serves whichever module loads first.
"POST /api/train-scheduling/schedules/:id/maintenance": ["Reschedule train for maintenance (new departure + rebalance)", "POST", "Schedule"],
"POST /api/train-scheduling/schedules/:id/reschedule/execute": ["Execute a confirmed reschedule plan", "POST", "Schedule"],
"POST /api/train-scheduling/schedules/:id/reschedule/preview": ["Preview reschedule / government preempt plan", "POST", "Schedule"],
// Service Type
"POST /api/service-types": ["Create a service type", "POST", "Service Type"],
"PATCH /api/service-types/:id": ["Update a service type", "PATCH", "Service Type"],
"DELETE /api/service-types/:id": ["Soft-delete a service type", "DELETE", "Service Type"],
"POST /api/service-types/:id/move-order": ["Move a service type up or down in display order", "POST", "Service Type"],
"POST /api/service-types/reorder": ["Bulk reorder service types by ID list", "POST", "Service Type"],
// Shipping Line
"POST /api/shipping-lines": ["Create a shipping line", "POST", "Shipping Line"],
"PATCH /api/shipping-lines/:id": ["Update a shipping line", "PATCH", "Shipping Line"],
"DELETE /api/shipping-lines/:id": ["Soft-delete a shipping line", "DELETE", "Shipping Line"],
// Signature
"PUT /api/me/signature": ["Create or update the reusable saved signature", "PUT", "Signature"],
// Support Chat
"POST /api/support/agent/conversations": ["Start chatting with a company (returns the thread if one exists)", "POST", "Support Chat"],
"POST /api/support/agent/conversations/:id/messages": ["Reply as an agent, optionally with attachments", "POST", "Support Chat"],
"POST /api/support/agent/conversations/:id/read": ["Mark a thread read (agent side)", "POST", "Support Chat"],
"POST /api/support/conversation/messages": ["Send a message as the customer (optionally with attachments), opening the thread if needed", "POST", "Support Chat"],
"POST /api/support/conversation/read": ["Mark my company's thread read (customer side)", "POST", "Support Chat"],
// Support Content
"PATCH /api/support-content/documents/:slug": ["Replace a document's payload, recording a new version", "PATCH", "Support Content"],
"POST /api/support-content/documents/:slug/versions/:version/restore": ["Restore a version — re-saves it as a new version, never destructive", "POST", "Support Content"],
"POST /api/support-content/media": ["Upload an image or video for a help section", "POST", "Support Content"],
// Train
"POST /api/trains": ["Register a new train", "POST", "Train"],
"PATCH /api/trains/:id": ["Update a train", "PATCH", "Train"],
"DELETE /api/trains/:id": ["Delete a train", "DELETE", "Train"],
// Train Build
"POST /api/train-builder": ["Build a train: code + yard + 2+ locomotives (+ optional wagons)", "POST", "Train Build"],
"DELETE /api/train-builder/:id": ["Disband the train (release wagons and locomotives)", "DELETE", "Train Build"],
"POST /api/train-builder/:id/activate": ["Reactivate a deactivated train back to AVAILABLE", "POST", "Train Build"],
"POST /api/train-builder/:id/deactivate": ["Deactivate the train (park it) — only allowed with no active schedule", "POST", "Train Build"],
"PATCH /api/train-builder/:id/details": ["Edit the train's name and fixed import/export run numbers", "PATCH", "Train Build"],
"PUT /api/train-builder/:id/locomotives": ["Replace the locomotive set (minimum 1, same yard)", "PUT", "Train Build"],
"POST /api/train-builder/:id/reorder-wagons": ["Persist a drag-reorder of the full consist", "POST", "Train Build"],
"POST /api/train-builder/:id/wagons": ["Append AVAILABLE wagons from the train's yard to the consist", "POST", "Train Build"],
"DELETE /api/train-builder/:id/wagons/:wagonId": ["Detach one wagon from the consist", "DELETE", "Train Build"],
"POST /api/train-builder/:id/wagons/:wagonId/maintenance": ["Detach one wagon and move it to MAINTENANCE status", "POST", "Train Build"],
"PATCH /api/train-builder/:id/yard": ["Relocate the train — its locomotives and wagons move to the new yard with it", "PATCH", "Train Build"],
// Train Schedule
"POST /api/train-scheduling/bookings/:bookingId/allocate": ["Staff: place a paid booking onto a fitting train (notifies customer on date change)", "POST", "Train Schedule"],
"POST /api/train-scheduling/bookings/:bookingId/expire": ["Staff: expire a reservation and free its capacity", "POST", "Train Schedule"],
"POST /api/train-scheduling/bookings/:bookingId/mark-paid": ["Staff: mark a reserved booking paid and allocate it now", "POST", "Train Schedule"],
"POST /api/train-scheduling/bookings/:bookingId/move-schedule": ["Re-point a booking to another OPEN same-route schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/bulk/preview": ["Preview a bulk train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/bulk/schedules": ["Create a bulk train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/bulk/schedules/:id/assign-bookings": ["Assign bulk bookings to a train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/bulk/schedules/:id/cancel": ["Cancel bulk train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/container/preview": ["Preview a container train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/container/schedules": ["Create a container train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/container/schedules/:id/assign-bookings": ["Assign container bookings to a train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/container/schedules/:id/cancel": ["Cancel container train schedule", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/global-rules": ["Update global train scheduling rules (singleton)", "PATCH", "Train Schedule"],
"POST /api/train-scheduling/preview": ["Preview a mixed-capable train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/adjust-consist": ["Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/arrive": ["Mark a dispatched train arrived (move assets to destination yard, free assets)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/assign-bookings": ["Assign bookings to a train schedule (mixed-capable)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/assign-unassigned-booking": ["Assign one linked unallocated booking to wagons (preserves existing assignments)", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/booking-window": ["Open or close a schedule booking window", "PATCH", "Train Schedule"],
"DELETE /api/train-scheduling/schedules/:id/bookings/:bookingId": ["Unassign a booking from a train schedule", "DELETE", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/bookings/:bookingId/load": ["Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/bookings/:bookingId/unload": ["Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/checkpoints": ["Log the train passing a station (final station triggers arrival)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/confirm-loading": ["Confirm cargo loaded on the train (any direction; unblocks import-Djibouti dispatch)", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/container-items/:itemId": ["Update a container number on a wagon slot", "PATCH", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/dispatch": ["Dispatch a scheduled train", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/doc-review-complete": ["Staff finished document review early — run the batch/payment phase now (applies to the whole route-day group)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/finalize": ["Finalize a draft train schedule", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/import-djibouti/depart": ["Depart loaded import train from Djibouti", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/import-djibouti/documents": ["Upload/check an import Djibouti-side document", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/import-djibouti/gatepass-granted": ["Mark import Djibouti gatepass permission granted", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/import-djibouti/load-list": ["Generate import load list / marshalling document summary", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/import-djibouti/loaded-on-train": ["Confirm import cargo loaded on train at Djibouti", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/import-djibouti/ready-for-loading": ["Mark import train ready for loading at Djibouti", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/import-loading-status": ["Mark import bookings loaded/unloaded on this schedule (tracking only, does not affect dispatch)", "PATCH", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/intercity/:bookingId/load": ["Confirm intercity cargo loaded (train must be at the booking's origin yard)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/intercity/:bookingId/unload": ["Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/intercity/accept": ["Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking)", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/loading-status": ["Mark bookings loaded/unloaded on this schedule (any direction, pre-dispatch only)", "PATCH", "Train Schedule"],
// NOTE: duplicate route — also declared in modules/train-scheduling/controllers/train-scheduling.controller.ts:798.
// Two controllers register this same path; Nest serves whichever module loads first.
"POST /api/train-scheduling/schedules/:id/maintenance [modules/train-scheduling/controllers/train-scheduling.controller.ts]": ["Maintenance reschedule: move the train to a new departure with every allocated booking aboard — links, wagons and window settings unchanged", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/pin-wagons": ["Pin physical wagons to train set slots", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/run-allocation": ["Run wagon-level allocation for all eligible linked bookings", "POST", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/run-batch": ["Manually run the batch fill for a schedule", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/schedule-date": ["Reschedule a train's departure date — only before the booking window opens, and only if the new date still leaves room for the booking lead window", "PATCH", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/train-number": ["Edit a departure's train number and voyage number — allowed only until the train is dispatched", "PATCH", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/switch-government-booking": ["Switch out commercial bookings to allocate a government booking in their place", "POST", "Train Schedule"],
"DELETE /api/train-scheduling/schedules/:id/wagons/:trainSetWagonId": ["Remove an empty wagon slot from a train", "DELETE", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/wagons/:wagonId/move-load": ["Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/window-rule": ["Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens", "PATCH", "Train Schedule"],
// Transit Agent
"POST /api/transit-agents": ["Create a transit agent", "POST", "Transit Agent"],
"PATCH /api/transit-agents/:id": ["Update a transit agent", "PATCH", "Transit Agent"],
"DELETE /api/transit-agents/:id": ["Soft-delete a transit agent", "DELETE", "Transit Agent"],
// Truck Type
"POST /api/truck-types": ["Create a truck type", "POST", "Truck Type"],
"PATCH /api/truck-types/:id": ["Update a truck type", "PATCH", "Truck Type"],
"DELETE /api/truck-types/:id": ["Soft-delete a truck type", "DELETE", "Truck Type"],
// User Trade Access
"PUT /api/user-trade-access/:userId": ["Set the trade directions a backoffice user may see", "PUT", "User Trade Access"],
// Vehicle
"POST /api/vehicles": ["Create a new vehicle", "POST", "Vehicle"],
"PATCH /api/vehicles/:id": ["Update a vehicle", "PATCH", "Vehicle"],
"DELETE /api/vehicles/:id": ["Delete a vehicle", "DELETE", "Vehicle"],
// Wagon
"POST /api/wagons": ["Create a new wagon", "POST", "Wagon"],
"PATCH /api/wagons/:id": ["Update a wagon", "PATCH", "Wagon"],
"DELETE /api/wagons/:id": ["Delete a wagon", "DELETE", "Wagon"],
"POST /api/wagons/:id/assign-train": ["Assign wagon to a train", "POST", "Wagon"],
"DELETE /api/wagons/:id/permanent": ["Permanently delete a wagon (irreversible; refused if it has movements, containers or train-set slots)", "DELETE", "Wagon"],
"POST /api/wagons/:id/unassign-train": ["Unassign wagon from train", "POST", "Wagon"],
"POST /api/wagons/bulk-status": ["Set the status of multiple wagons (audited in wagon_status_logs)", "POST", "Wagon"],
"POST /api/wagons/bulk-transfer": ["Transfer multiple wagons to a destination yard", "POST", "Wagon"],
// Wagon Transfer Request
"POST /api/wagon-transfer-requests": ["File a count-only wagon-transfer request", "POST", "Wagon Transfer Request"],
"POST /api/wagon-transfer-requests/:id/cancel": ["Withdraw a request that has not moved any wagon yet (use close-short once wagons have moved)", "POST", "Wagon Transfer Request"],
"POST /api/wagon-transfer-requests/:id/close-short": ["OCC: end the request with fewer wagons than asked for — what moved stays, the requester is told the shortfall", "POST", "Wagon Transfer Request"],
"POST /api/wagon-transfer-requests/:id/fulfill": ["OCC: pick wagons and execute the transfer", "POST", "Wagon Transfer Request"],
"POST /api/wagon-transfer-requests/bulk-fulfill": ["OCC: accept-and-execute a subset of pending requests (auto-picks available wagons; the rest stay PENDING)", "POST", "Wagon Transfer Request"],
// Wagon Type
"POST /api/wagon-types": ["Create a wagon type", "POST", "Wagon Type"],
"PATCH /api/wagon-types/:id": ["Update a wagon type", "PATCH", "Wagon Type"],
"DELETE /api/wagon-types/:id": ["Soft-delete a wagon type", "DELETE", "Wagon Type"],
// Warehouse
"POST /api/warehouse-allocation-rules": ["Create a warehouse allocation rule", "POST", "Warehouse"],
"PATCH /api/warehouse-allocation-rules/:id": ["Update a warehouse allocation rule", "PATCH", "Warehouse"],
"DELETE /api/warehouse-allocation-rules/:id": ["Delete a warehouse allocation rule", "DELETE", "Warehouse"],
"POST /api/warehouse-allocation/preview": ["Preview the yard/warehouse/zone a booking would be allocated to", "POST", "Warehouse"],
"POST /api/warehouse-fee-rules": ["Create a storage / demurrage fee rule", "POST", "Warehouse"],
"PATCH /api/warehouse-fee-rules/:id": ["Update a fee rule", "PATCH", "Warehouse"],
"DELETE /api/warehouse-fee-rules/:id": ["Delete a fee rule", "DELETE", "Warehouse"],
"POST /api/warehouse-fees/accrual/:inventoryId/acknowledge": ["Acknowledge / snooze an item fee-accrual alert", "POST", "Warehouse"],
"DELETE /api/warehouse-fees/accrual/:inventoryId/acknowledge": ["Remove an accrual acknowledgement (re-surface for alerts)", "DELETE", "Warehouse"],
"POST /api/warehouses": ["Create warehouse", "POST", "Warehouse"],
"PATCH /api/warehouses/:id": ["Update warehouse", "PATCH", "Warehouse"],
"POST /api/warehouses/:warehouseId/yards": ["Create a yard within a warehouse", "POST", "Warehouse"],
// Warehouse Fee Invoice
"POST /api/last-mile/:id/generate-truck-detention-invoice": ["Generate a truck-detention invoice for a last-mile leg (per truck per day)", "POST", "Warehouse Fee Invoice"],
"PATCH /api/warehouse-fee-invoices/:id/cancel": ["Cancel a warehouse fee invoice", "PATCH", "Warehouse Fee Invoice"],
"POST /api/warehouse-fee-invoices/:id/pay": ["Record a payment against a warehouse fee invoice", "POST", "Warehouse Fee Invoice"],
"POST /api/warehouse-fee-invoices/:id/pay-online": ["Initiate Telebirr/Waafi payment for a warehouse fee invoice", "POST", "Warehouse Fee Invoice"],
"POST /api/warehouse-inventory/:id/generate-fee-invoice": ["Generate a warehouse fee invoice from Batch 5 fee calculation", "POST", "Warehouse Fee Invoice"],
// Warehouse Inspection Report
"PATCH /api/warehouse-inspection-reports/:id": ["Update an inspection report", "PATCH", "Warehouse Inspection Report"],
"POST /api/warehouse-inspection-reports/:id/attachments": ["Upload inspection images / documents", "POST", "Warehouse Inspection Report"],
"POST /api/warehouse-inventory/:inventoryId/inspection-reports": ["Create an inspection / damage report for an inventory item", "POST", "Warehouse Inspection Report"],
// Warehouse Inventory
"POST /api/warehouse-inventory/:id/deliver": ["Deliver import goods to the customer + capture proof of delivery", "POST", "Warehouse Inventory"],
"PATCH /api/warehouse-inventory/:id/dispatch": ["Mark loaded inventory DISPATCHED (left the terminal)", "PATCH", "Warehouse Inventory"],
"POST /api/warehouse-inventory/:id/gate-clearance": ["Final terminal release / gate clearance (blocked while fees unpaid)", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/:id/load": ["Load READY_FOR_LOADING inventory onto a wagon", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/:id/move": ["Move inventory to another warehouse/yard/zone", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/:id/ready-for-loading": ["Mark reserved inventory READY_FOR_LOADING", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/:id/ready-for-pickup": ["Mark inspected IMPORT inventory READY_FOR_PICKUP", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/:id/release": ["Issue a DO / release order for ready-for-pickup inventory", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/:id/store": ["Mark received inventory as STORED (optional explicit warehouse/yard/zone)", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/auto-load-ready": ["Auto-load READY_FOR_LOADING inventory with PAID bookings", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/auto-unload-arrived": ["Bulk auto-unload all arrived bookings into the warehouse", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/bookings/:bookingId/approve-delivery": ["Approve delivery — customer records their full name (signature optional)", "POST", "Warehouse Inventory"],
"PATCH /api/warehouse-inventory/bookings/:bookingId/double-handling": ["Record Yes/No double handling after unloading (Yes applies the double-handling fee rule)", "PATCH", "Warehouse Inventory"],
"POST /api/warehouse-inventory/bookings/:bookingId/request-handover-signature": ["Ask the customer to sign the handover (creates one if none, then notifies)", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/bookings/:bookingId/unload": ["Unload a single arrived booking into a location", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/bulk-dispatch-export": ["Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/bulk-mark-inspected": ["Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING)", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/export/auto-unload-at-djibouti": ["Unload all eligible export items assigned to an arrived Djibouti-side train", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/handovers/:handoverId/sign": ["Customer signs one handover (EDR last-mile: one signature per truck)", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/import/auto-unload-arrived-bookings": ["Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/receive": ["Receive inventory at a warehouse location", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/receive-bulk": ["Bulk-receive selected eligible PAID bookings into a location", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/reserve": ["Reserve stored inventory for a PAID booking", "POST", "Warehouse Inventory"],
"POST /api/warehouse-inventory/train/:scheduleId/load": ["Load selected inventory items onto their allocated wagons for a train", "POST", "Warehouse Inventory"],
// Warehouse Yard
"PATCH /api/warehouse-yards/:id": ["Update warehouse yard", "PATCH", "Warehouse Yard"],
"POST /api/warehouse-yards/:yardId/zones": ["Create a zone within a yard", "POST", "Warehouse Yard"],
// Warehouse Zone
"PATCH /api/warehouse-zones/:id": ["Update warehouse zone", "PATCH", "Warehouse Zone"],
// Weight Limit Rule
"POST /api/weight-limit-rules": ["Create a weight limit rule", "POST", "Weight Limit Rule"],
"PATCH /api/weight-limit-rules/:id": ["Update a weight limit rule", "PATCH", "Weight Limit Rule"],
"DELETE /api/weight-limit-rules/:id": ["Soft-delete a weight limit rule", "DELETE", "Weight Limit Rule"],
// Yard
"POST /api/yards": ["Create a yard", "POST", "Yard"],
"PATCH /api/yards/:id": ["Update a yard", "PATCH", "Yard"],
"DELETE /api/yards/:id": ["Soft-delete a yard", "DELETE", "Yard"],
"POST /api/yards/:id/move-order": ["Move a yard up or down in display order", "POST", "Yard"],
"POST /api/yards/reorder": ["Bulk reorder yards by ID list", "POST", "Yard"],
// Yard Distance
"POST /api/yard-distances": ["Create a yard distance", "POST", "Yard Distance"],
"PATCH /api/yard-distances/:id": ["Update a yard distance", "PATCH", "Yard Distance"],
"DELETE /api/yard-distances/:id": ["Soft-delete a yard distance", "DELETE", "Yard Distance"],
};

View File

@@ -0,0 +1,79 @@
import { Injectable } from '@nestjs/common';
import { BaseRepository } from '@edr/api-common';
import { InjectRepository } from '@nestjs/typeorm';
import { Between, FindOptionsWhere, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { AuditLog } from './entities/audit-log.entity';
export interface AuditLogQuery {
type?: string;
userId?: string;
method?: string;
isSuccess?: boolean;
resourceId?: string;
from?: Date;
to?: Date;
skip: number;
take: number;
}
@Injectable()
export class AuditLogRepository extends BaseRepository<AuditLog> {
constructor(
@InjectRepository(AuditLog)
private readonly auditLogRepository: Repository<AuditLog>,
) {
super(auditLogRepository);
}
/**
* Insert one audit row.
*
* `insert` rather than `save`: save would issue a SELECT first to decide
* between insert and update, which is wasted work for a table that is only
* ever appended to.
*/
async record(entry: Partial<AuditLog>): Promise<void> {
await this.auditLogRepository.insert(
entry as QueryDeepPartialEntity<AuditLog>,
);
}
/**
* Paginated, filtered read. Newest first — every index on this table is
* ordered `created_at DESC` to match.
*/
async search(query: AuditLogQuery): Promise<[AuditLog[], number]> {
const where: FindOptionsWhere<AuditLog> = {};
if (query.type) where.type = query.type;
if (query.userId) where.userId = query.userId;
if (query.method) where.method = query.method;
if (query.resourceId) where.resourceId = query.resourceId;
if (query.isSuccess !== undefined) where.isSuccess = query.isSuccess;
// Date range: either bound may be supplied alone.
if (query.from && query.to) where.createdAt = Between(query.from, query.to);
else if (query.from) where.createdAt = MoreThanOrEqual(query.from);
else if (query.to) where.createdAt = LessThanOrEqual(query.to);
return this.auditLogRepository.findAndCount({
where,
order: { createdAt: 'DESC' },
skip: query.skip,
take: query.take,
});
}
/** Distinct entity types present, for populating a filter dropdown. */
async distinctTypes(): Promise<string[]> {
const rows = await this.auditLogRepository
.createQueryBuilder('audit_log')
.select('DISTINCT audit_log.type', 'type')
.orderBy('audit_log.type', 'ASC')
.getRawMany<{ type: string }>();
return rows.map((row) => row.type);
}
}

View File

@@ -0,0 +1,46 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { PaginatedResponse } from '@edr/types';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { AuditService } from './audit.service';
import { AuditLog } from './entities/audit-log.entity';
import { AuditLogQueryDto } from './dto/audit-log-query.dto';
/**
* Read-only view over the audit trail.
*
* Gated on `edr_freight_app:audit_log:view` — a dedicated view key rather than
* the broad `admin` key, so reading the trail can be granted without also
* granting write access to everything else.
*
* There is deliberately no write, update or delete endpoint here — rows are
* created only by `AuditInterceptor`, and an audit trail that can be edited
* through the API is not an audit trail.
*/
@ApiTags('audit')
@ApiBearerAuth()
@Controller('audit')
export class AuditController {
constructor(private readonly auditService: AuditService) {}
@Get('logs')
@BookingStaff(FREIGHT_PERMS.auditLog.view)
@ApiOperation({
summary:
'List backoffice audit logs — filter by entity type, user, method, outcome and date range',
})
list(@Query() query: AuditLogQueryDto): Promise<PaginatedResponse<AuditLog>> {
return this.auditService.search(query);
}
@Get('types')
@BookingStaff(FREIGHT_PERMS.auditLog.view)
@ApiOperation({
summary: 'Distinct entity types present in the audit log (filter dropdown)',
})
types(): Promise<string[]> {
return this.auditService.listTypes();
}
}

View File

@@ -0,0 +1,189 @@
import {
CallHandler,
ExecutionContext,
HttpException,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import type { Request, Response } from 'express';
import { AuditService } from './audit.service';
import {
auditEndpointMatcher,
type MatchedAuditEndpoint,
} from './audit-endpoint-matcher';
import {
isAuditableActor,
resolveAuditActor,
type AuditActorSource,
} from './audit-actor';
import { redactUrlQuery, sanitizeRequestPayload } from './audit.sanitizer';
/** Methods that can change state. Everything else is never audited. */
const AUDITED_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
/** `error_message` ceiling — stack traces do not belong in this column. */
const MAX_ERROR_LENGTH = 2_000;
type RequestWithUser = Request & {
user?: AuditActorSource;
files?: unknown;
file?: unknown;
id?: string;
};
/**
* Writes one `audit_logs` row per state-changing backoffice request.
*
* An interceptor rather than the two middlewares originally sketched, for one
* decisive reason: Express middleware runs BEFORE guards, so `req.user` is not
* populated yet. Both the backoffice-only rule and `user_id` would be
* unavailable there. Interceptors run after guards and wrap the handler's
* result, so a single class covers both halves — request context on the way in,
* outcome on the way out — sharing one timer for `duration_ms`.
*
* Registered globally (see `audit.module.ts`), so new routes are covered
* automatically as long as they appear in `AUDIT_ENDPOINTS`.
*/
@Injectable()
export class AuditInterceptor implements NestInterceptor {
constructor(private readonly auditService: AuditService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
// Non-HTTP contexts (the RabbitMQ microservice transport) have no request.
if (context.getType() !== 'http') return next.handle();
const httpContext = context.switchToHttp();
const request = httpContext.getRequest<RequestWithUser>();
if (!AUDITED_METHODS.has(request.method)) return next.handle();
// Backoffice only. Customers and unauthenticated callers are skipped
// outright — decided in `audit-actor.ts`, which reuses the same
// `userType` discriminator as the permission guards.
if (!isAuditableActor(request.user)) return next.handle();
const matched = auditEndpointMatcher.match(request.method, request.originalUrl);
// Not in AUDIT_ENDPOINTS means the route is not a known auditable action;
// recording it would produce rows with no title or entity.
if (!matched) return next.handle();
const startedAt = Date.now();
// The body is captured up front: handlers are free to mutate the DTO they
// are given, so reading it after the fact can record post-mutation values.
const requestPayload = sanitizeRequestPayload(
request.body,
request.files ?? request.file,
);
return next.handle().pipe(
tap({
next: () => {
const response = httpContext.getResponse<Response>();
void this.write(request, matched, requestPayload, startedAt, {
isSuccess: true,
// Nest has not applied the handler's @HttpCode yet at this point
// for some routes; statusCode on the response object is the value
// actually being sent.
statusCode: response.statusCode,
errorMessage: null,
});
},
error: (error: unknown) => {
void this.write(request, matched, requestPayload, startedAt, {
isSuccess: false,
statusCode: resolveErrorStatus(error),
errorMessage: resolveErrorMessage(error),
});
},
}),
);
}
/**
* Build and persist the row.
*
* Deliberately not awaited by `intercept`: the audit write must not add
* latency to the request, and `AuditService.record` already swallows its own
* failures so a rejected promise cannot surface as an unhandled rejection.
*/
private async write(
request: RequestWithUser,
matched: MatchedAuditEndpoint,
requestPayload: Record<string, unknown> | null,
startedAt: number,
outcome: {
isSuccess: boolean;
statusCode: number | null;
errorMessage: string | null;
},
): Promise<void> {
const actor = resolveAuditActor(request.user as AuditActorSource);
await this.auditService.record({
title: matched.title,
method: request.method,
// Full URL including query string, with sensitive query values redacted.
url: redactUrlQuery(request.originalUrl),
routePath: matched.routePath,
type: matched.type,
isSuccess: outcome.isSuccess,
statusCode: outcome.statusCode,
errorMessage: outcome.errorMessage,
userId: actor.userId,
userName: actor.userName,
userRole: actor.userRole,
resourceId: matched.resourceId,
request: requestPayload,
ipAddress: resolveIp(request),
userAgent: request.headers['user-agent'] ?? null,
requestId: resolveRequestId(request),
durationMs: Date.now() - startedAt,
});
}
}
/** HTTP status for the failure, falling back to 500 for non-HTTP errors. */
function resolveErrorStatus(error: unknown): number {
return error instanceof HttpException ? error.getStatus() : 500;
}
/** Message only — stack traces belong in application logs, not this column. */
function resolveErrorMessage(error: unknown): string | null {
if (error instanceof HttpException) {
const response = error.getResponse();
const message =
typeof response === 'string'
? response
: ((response as { message?: unknown })?.message ?? error.message);
const text = Array.isArray(message) ? message.join('; ') : String(message);
return text.slice(0, MAX_ERROR_LENGTH);
}
if (error instanceof Error) return error.message.slice(0, MAX_ERROR_LENGTH);
return error ? String(error).slice(0, MAX_ERROR_LENGTH) : null;
}
/**
* Client IP. The API sits behind a reverse proxy, so `req.ip` is the proxy
* unless `trust proxy` is set; the forwarded header is preferred and its first
* entry (the original client) taken.
*/
function resolveIp(request: Request): string | null {
const forwarded = request.headers['x-forwarded-for'];
const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded;
const candidate = raw?.split(',')[0]?.trim() || request.ip;
if (!candidate) return null;
// Normalize IPv4-mapped IPv6 (`::ffff:10.0.0.1`), which the `inet` column
// accepts but which reads badly and breaks grouping by address.
return candidate.startsWith('::ffff:') ? candidate.slice(7) : candidate;
}
/** Correlation id from the proxy/tracing layer, when present. */
function resolveRequestId(request: RequestWithUser): string | null {
const header = request.headers['x-request-id'] ?? request.headers['x-correlation-id'];
const value = Array.isArray(header) ? header[0] : header;
return (value ?? request.id ?? null)?.toString().slice(0, 64) ?? null;
}

View File

@@ -0,0 +1,34 @@
import { Global, Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditController } from './audit.controller';
import { AuditInterceptor } from './audit.interceptor';
import { AuditLog } from './entities/audit-log.entity';
import { AuditLogRepository } from './audit-log.repository';
import { AuditService } from './audit.service';
/**
* Backoffice audit trail.
*
* `AuditInterceptor` is bound through `APP_INTERCEPTOR`, so it applies to every
* route in the application without touching the 488 mutating handlers
* individually. Coverage therefore follows `AUDIT_ENDPOINTS`: a new route is
* audited as soon as it appears in that map, and unknown routes are skipped
* rather than recorded with an empty title.
*
* Global so other modules can inject `AuditService` to record domain events
* that do not map cleanly onto an HTTP request.
*/
@Global()
@Module({
imports: [TypeOrmModule.forFeature([AuditLog])],
controllers: [AuditController],
providers: [
AuditLogRepository,
AuditService,
{ provide: APP_INTERCEPTOR, useClass: AuditInterceptor },
],
exports: [AuditService, AuditLogRepository],
})
export class AuditModule {}

View File

@@ -0,0 +1,195 @@
/**
* Redaction and shrinking for anything copied into `audit_logs.request`.
*
* This matters more here than in a typical audit log. `main.ts` raises the JSON
* body ceiling to 100MB so contract signing can post a signature AND a company
* stamp as base64 in one request. Copying a body like that verbatim would put
* both a credential-grade artefact and a 100MB blob into the audit table, on
* the write path of every audited endpoint.
*/
const REDACTED = '[REDACTED]';
/**
* Substring-matched against lower-cased key names, so `newPassword`,
* `otpCode` and `x-authorization` are all caught without enumerating variants.
*
* `signature` and `stamp` are here because contract signing posts both as
* base64 — they are simultaneously the largest and the most sensitive fields
* this API accepts.
*/
const SENSITIVE_KEY_PATTERNS = [
'password',
'otp',
'token',
'secret',
'pin',
'authorization',
'signature',
'stamp',
'apikey',
'api_key',
'credential',
'ssn',
];
/** Serialized `request` ceiling. Beyond this the payload is dropped for a marker. */
const MAX_REQUEST_BYTES = 64 * 1024;
/** Depth guard: deep nesting is never worth the recursion cost here. */
const MAX_DEPTH = 6;
/** Long strings (base64 blobs) are truncated rather than stored whole. */
const MAX_STRING_LENGTH = 2_000;
function isSensitiveKey(key: string): boolean {
const lower = key.toLowerCase();
return SENSITIVE_KEY_PATTERNS.some((pattern) => lower.includes(pattern));
}
/**
* Multer file shape, reduced to a descriptor. The buffer is never stored —
* Postgres is the wrong home for file bytes, and `audit_logs` doubly so.
*/
function isMulterFile(value: unknown): boolean {
if (typeof value !== 'object' || value === null) return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate.originalname === 'string' &&
(typeof candidate.mimetype === 'string' || typeof candidate.size === 'number')
);
}
function describeFile(value: Record<string, unknown>): Record<string, unknown> {
return {
__file: true,
originalName: value.originalname ?? null,
mimeType: value.mimetype ?? null,
size: typeof value.size === 'number' ? value.size : null,
fieldName: value.fieldname ?? null,
};
}
function sanitizeValue(value: unknown, depth: number): unknown {
if (value === null || value === undefined) return value ?? null;
if (typeof value === 'string') {
return value.length > MAX_STRING_LENGTH
? `${value.slice(0, MAX_STRING_LENGTH)}…[truncated ${value.length} chars]`
: value;
}
if (typeof value === 'number' || typeof value === 'boolean') return value;
if (value instanceof Date) return value.toISOString();
// Buffers are file bytes by definition — never persisted, only described.
if (Buffer.isBuffer(value)) return { __buffer: true, size: value.length };
if (depth >= MAX_DEPTH) return '[MAX_DEPTH]';
if (Array.isArray(value)) {
// Cap array length: bulk endpoints post large collections.
const capped = value.slice(0, 50).map((item) => sanitizeValue(item, depth + 1));
if (value.length > 50) capped.push(`…[${value.length - 50} more items]`);
return capped;
}
if (typeof value === 'object') {
if (isMulterFile(value)) return describeFile(value as Record<string, unknown>);
const out: Record<string, unknown> = {};
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
out[key] = isSensitiveKey(key) ? REDACTED : sanitizeValue(nested, depth + 1);
}
return out;
}
// Functions, symbols and anything else are not audit data.
return null;
}
/**
* Sanitize a request body (or query object) for storage.
*
* Returns null when there is nothing worth keeping, so empty bodies do not
* occupy jsonb rows.
*/
export function sanitizeRequestPayload(
body: unknown,
files?: unknown,
): Record<string, unknown> | null {
const payload: Record<string, unknown> = {};
if (body && typeof body === 'object' && Object.keys(body).length > 0) {
const sanitizedBody = sanitizeValue(body, 0);
if (sanitizedBody && typeof sanitizedBody === 'object') {
Object.assign(payload, sanitizedBody as Record<string, unknown>);
}
}
// Multer puts uploads on `req.files`, outside `req.body`, so they are folded
// in explicitly — otherwise a pure-upload request records an empty payload.
if (files) {
const sanitizedFiles = sanitizeValue(files, 0);
if (
sanitizedFiles &&
(Array.isArray(sanitizedFiles) || typeof sanitizedFiles === 'object')
) {
const hasEntries = Array.isArray(sanitizedFiles)
? sanitizedFiles.length > 0
: Object.keys(sanitizedFiles as object).length > 0;
if (hasEntries) payload.__uploads = sanitizedFiles;
}
}
if (Object.keys(payload).length === 0) return null;
// Final size guard. A body can stay under every per-field cap and still be
// enormous in aggregate, so the serialized form is measured before storing.
const serialized = JSON.stringify(payload);
if (serialized && Buffer.byteLength(serialized, 'utf8') > MAX_REQUEST_BYTES) {
return {
__truncated: true,
reason: 'Payload exceeded the audit size limit',
bytes: Buffer.byteLength(serialized, 'utf8'),
keys: Object.keys(payload).slice(0, 50),
};
}
return payload;
}
/**
* Rebuild a URL with sensitive query values redacted.
*
* `url` is stored with its full query string, and query strings are a common
* place for one-time tokens and signed links, so the same deny-list that
* protects the body is applied to the query.
*/
export function redactUrlQuery(url: string): string {
const queryIndex = url.indexOf('?');
if (queryIndex === -1) return url;
const path = url.slice(0, queryIndex);
const query = url.slice(queryIndex + 1);
if (!query) return path;
const redacted = query
.split('&')
.map((pair) => {
const eq = pair.indexOf('=');
if (eq === -1) return pair;
const key = pair.slice(0, eq);
// Keys arrive percent-encoded; decode before matching so `api%2Dkey`
// is not treated as harmless.
let decodedKey = key;
try {
decodedKey = decodeURIComponent(key);
} catch {
/* malformed encoding — fall back to the raw key */
}
return isSensitiveKey(decodedKey) ? `${key}=${REDACTED}` : pair;
})
.join('&');
return `${path}?${redacted}`;
}

View File

@@ -0,0 +1,71 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { PaginatedResponse } from '@edr/types';
import { AuditLog } from './entities/audit-log.entity';
import { AuditLogRepository } from './audit-log.repository';
import { AuditLogQueryDto } from './dto/audit-log-query.dto';
import {
buildPaginationMeta,
normalizePagination,
} from '../../common/utils/pagination.util';
@Injectable()
export class AuditService {
private readonly logger = new Logger(AuditService.name);
constructor(private readonly auditLogRepository: AuditLogRepository) {}
/**
* Persist one audit row, swallowing any failure.
*
* An audit write must never turn a successful business action into an error
* for the user: if this table is full, misconfigured or mid-migration,
* contract approvals still need to work. Failures are logged so the gap is
* visible in application logs rather than silent.
*/
async record(entry: Partial<AuditLog>): Promise<void> {
try {
await this.auditLogRepository.record(entry);
} catch (error) {
this.logger.error(
`Failed to write audit log for ${entry.method} ${entry.routePath}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
/** Paginated, filtered audit history, newest first. */
async search(query: AuditLogQueryDto): Promise<PaginatedResponse<AuditLog>> {
const { page, pageSize, skip, take } = normalizePagination(query);
const from = query.from ? new Date(query.from) : undefined;
const to = query.to ? new Date(query.to) : undefined;
// A reversed range silently returns zero rows, which reads as "nothing
// happened" rather than "your filter is wrong" — reject it explicitly.
if (from && to && from > to) {
throw new BadRequestException('`from` must be earlier than `to`');
}
const [items, total] = await this.auditLogRepository.search({
type: query.type,
userId: query.userId,
method: query.method,
resourceId: query.resourceId,
isSuccess:
query.isSuccess === undefined ? undefined : query.isSuccess === 'true',
from,
to,
skip,
take,
});
return { items, meta: buildPaginationMeta(total, page, pageSize) };
}
/** Distinct entity types, for the filter dropdown on the audit screen. */
async listTypes(): Promise<string[]> {
return this.auditLogRepository.distinctTypes();
}
}

View File

@@ -0,0 +1,64 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBooleanString, IsIn, IsISO8601, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
const AUDITED_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'] as const;
/**
* Filters for the audit log read endpoint.
*
* Extends the shared pagination DTO so page/pageSize behave (and are capped)
* exactly as they do on every other list endpoint.
*/
export class AuditLogQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({
description: 'Entity type, e.g. "Contract", "Booking", "Locomotive".',
example: 'Contract',
})
@IsOptional()
@IsString()
@MaxLength(50)
type?: string;
@ApiPropertyOptional({ description: 'IAM id of the acting backoffice user.' })
@IsOptional()
@IsUUID()
userId?: string;
@ApiPropertyOptional({ enum: AUDITED_METHODS })
@IsOptional()
@Transform(({ value }) => String(value).toUpperCase())
@IsIn([...AUDITED_METHODS])
method?: string;
@ApiPropertyOptional({ description: 'Id of the affected record.' })
@IsOptional()
@IsString()
@MaxLength(64)
resourceId?: string;
@ApiPropertyOptional({
description: 'Filter by outcome: true = succeeded, false = failed.',
})
@IsOptional()
@IsBooleanString()
isSuccess?: string;
@ApiPropertyOptional({
description: 'Inclusive start of the range (ISO 8601).',
example: '2026-01-01T00:00:00.000Z',
})
@IsOptional()
@IsISO8601()
from?: string;
@ApiPropertyOptional({
description: 'Inclusive end of the range (ISO 8601).',
example: '2026-01-31T23:59:59.999Z',
})
@IsOptional()
@IsISO8601()
to?: string;
}

View File

@@ -0,0 +1,132 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
/**
* One backoffice action against a state-changing endpoint.
*
* Deliberately does NOT extend `BaseEntity`, which is the repo standard
* everywhere else. `BaseEntity` carries `updatedAt` and `deletedAt`, and both
* are wrong here:
*
* - `updatedAt` implies an audit row can be edited. A record that can be
* rewritten after the fact is not evidence.
* - `deletedAt` (soft delete) would let anyone who can delete erase their own
* trail, and TypeORM would then hide those rows from every default query —
* the failure would be silent, which is the worst property an audit log can
* have.
*
* Rows are insert-only: nothing in this module updates or deletes them.
*
* `userId` is a bare uuid with NO foreign key into the `iam` schema. Two
* reasons: cross-schema FKs are forbidden platform-wide, and a FK would let
* deleting a user cascade away the record of what that user did — exactly
* backwards. `userName` / `userRole` are point-in-time snapshots for the same
* reason: resolving them at read time would rewrite history whenever somebody
* is renamed or changes role.
*/
@Entity({ schema: 'freight', name: 'audit_logs' })
// Every audit query is time-bounded, so created_at leads most indexes.
@Index('IDX_audit_logs_created_at', ['createdAt'])
@Index('IDX_audit_logs_user_id_created_at', ['userId', 'createdAt'])
@Index('IDX_audit_logs_type_created_at', ['type', 'createdAt'])
@Index('IDX_audit_logs_type_resource_id', ['type', 'resourceId'])
@Index('IDX_audit_logs_route_path_created_at', ['routePath', 'createdAt'])
export class AuditLog {
@PrimaryGeneratedColumn('uuid')
id!: string;
/**
* Human-readable action, e.g. "Approve contract" — taken from the matched
* entry in `AUDIT_ENDPOINTS`, which sources it from each route's
* `@ApiOperation` summary.
*/
@Column({ name: 'title', type: 'varchar', length: 255 })
title!: string;
@Column({ name: 'method', type: 'varchar', length: 10 })
method!: string;
/**
* The URL as actually called, real ids and query string included
* (`/api/contracts/abc-123/cancel?force=true`). Query values run through the
* same redaction pass as the body, so a `?token=` never lands here.
*/
@Column({ name: 'url', type: 'text' })
url!: string;
/**
* The route template (`/api/contracts/:id/cancel`).
*
* `url` alone cannot be grouped — every contract cancel is a distinct string.
* This column is the join key back to `AUDIT_ENDPOINTS` and makes
* "every contract cancellation" one indexed query instead of a regex scan.
*/
@Column({ name: 'route_path', type: 'varchar', length: 255, nullable: true })
routePath?: string | null;
/** Primary entity the action touched: `Contract`, `Booking`, `Locomotive`. */
@Column({ name: 'type', type: 'varchar', length: 50 })
type!: string;
@Column({ name: 'is_success', type: 'boolean' })
isSuccess!: boolean;
/** IAM user id. Nullable by design — see the class comment. */
@Column({ name: 'user_id', type: 'uuid', nullable: true })
userId?: string | null;
/**
* Id of the affected record, recovered from the first path parameter of the
* matched template.
*
* `varchar`, not `uuid`: not every identifier is a uuid
* (`/api/contract-templates/:code`), and a create has no id at all until it
* succeeds. A `uuid NOT NULL` column would throw during the write and lose
* the audit row rather than the id.
*/
@Column({ name: 'resource_id', type: 'varchar', length: 64, nullable: true })
resourceId?: string | null;
/**
* Sanitized request body. Secrets are replaced with `[REDACTED]` and uploads
* are reduced to `{ __file, originalName, mimeType, size }` descriptors —
* never raw bytes. See `audit.sanitizer.ts`.
*/
@Column({ name: 'request', type: 'jsonb', nullable: true })
request?: Record<string, unknown> | null;
/**
* `isSuccess` alone cannot separate 403 (denied — the security signal worth
* alerting on) from 500 (broke). Both are simply `false`.
*/
@Column({ name: 'status_code', type: 'smallint', nullable: true })
statusCode?: number | null;
@Column({ name: 'error_message', type: 'text', nullable: true })
errorMessage?: string | null;
/** Snapshot of the actor's display name at the time of the action. */
@Column({ name: 'user_name', type: 'varchar', length: 150, nullable: true })
userName?: string | null;
/** Snapshot of the actor's role at the time of the action. */
@Column({ name: 'user_role', type: 'varchar', length: 100, nullable: true })
userRole?: string | null;
/** Non-repudiation: the first thing asked in any incident review. */
@Column({ name: 'ip_address', type: 'inet', nullable: true })
ipAddress?: string | null;
/** Helps separate a real browser session from a script using a stolen token. */
@Column({ name: 'user_agent', type: 'text', nullable: true })
userAgent?: string | null;
/** Correlates this row with application logs/traces for the same request. */
@Column({ name: 'request_id', type: 'varchar', length: 64, nullable: true })
requestId?: string | null;
@Column({ name: 'duration_ms', type: 'integer', nullable: true })
durationMs?: number | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
}

View File

@@ -1267,18 +1267,18 @@ export class BookingsController {
@Post(':id/clearance/delivery-order')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(FileInterceptor('file'))
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
async uploadBookingDeliveryOrder(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@UploadedFiles() files: Express.Multer.File[],
@Body('vesselArrivalDate') vesselArrivalDate: string | undefined,
@Body('doCollectedDate') doCollectedDate: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
id,
file,
files ?? [],
resolveAuthUserId(user),
{ vesselArrivalDate, doCollectedDate },
);
@@ -1287,17 +1287,17 @@ export class BookingsController {
@Post(':id/clearance/release-order')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(FileInterceptor('file'))
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
async uploadBookingReleaseOrder(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@UploadedFiles() files: Express.Multer.File[],
@Body('vesselDepartureDate') vesselDepartureDate: string,
@CurrentUser() user: TCurrentUser,
) {
const result = await this.bookingClearanceService.uploadReleaseOrder(
id,
file,
files ?? [],
vesselDepartureDate,
resolveAuthUserId(user),
);

View File

@@ -59,9 +59,11 @@ import { PdfRenderService } from '../billing/documents/pdf-render.service';
import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util';
/**
* The allocated train as the backoffice booking detail page needs it: which
* train, its window phase, and both the planned and actual clock. Attached by
* `findById` only when the booking is on a schedule.
* The train as the backoffice booking detail page needs it: which train, its
* window phase, and both the planned and actual clock. Attached by `findById`
* for the allocated train (`train_schedule_id`) or, before the batch engine has
* allocated one, the train the customer picked at day-commit
* (`requested_train_schedule_id`) — see `isRequested`.
*/
export interface TrainScheduleSummary {
id: string;
@@ -74,6 +76,12 @@ export interface TrainScheduleSummary {
actualArrivalAt: string | null;
windowPhase: string | null;
paymentPhaseEndsAt: string | null;
/**
* True when this is the customer's requested train rather than a confirmed
* allocation — the state staff review at OPERATION_REQUEST_PENDING, before
* accepting the operation puts the booking into the batch pool.
*/
isRequested: boolean;
}
/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
@@ -2132,15 +2140,23 @@ export class BookingsService {
// Surface the assigned train's operational status so the portal stepper
// can show the Arrival stage: the booking status stays IN_TRANSIT from
// dispatch until delivery, so arrival is only knowable from the schedule.
if (booking.trainScheduleId) {
// The allocated train, or — before the batch engine has allocated one — the
// train the customer picked at day-commit. Staff reviewing an operation
// request (OPERATION_REQUEST_PENDING) must see which train they are
// accepting onto before they approve, and at that point only the requested
// id is set.
const summarySourceId = booking.trainScheduleId ?? booking.requestedTrainScheduleId;
if (summarySourceId) {
const schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: booking.trainScheduleId } });
.findOne({ where: { id: summarySourceId } });
// trainScheduleStatus drives the portal's Arrival stage, so it stays tied
// to a real allocation — a merely requested train has not departed.
(booking as Booking & { trainScheduleStatus?: string | null }).trainScheduleStatus =
schedule?.status ?? null;
// Backoffice staff view: the allocated train's identity and clock, so the
// detail page can state which train the booking rides and when it runs
// without a second round-trip to the schedules API.
booking.trainScheduleId ? (schedule?.status ?? null) : null;
// Backoffice staff view: the train's identity and clock, so the detail
// page can state which train the booking rides and when it runs without a
// second round-trip to the schedules API.
(
booking as Booking & { trainScheduleSummary?: TrainScheduleSummary | null }
).trainScheduleSummary = schedule
@@ -2155,6 +2171,7 @@ export class BookingsService {
actualArrivalAt: schedule.actualArrivalAt?.toISOString() ?? null,
windowPhase: schedule.windowPhase ?? null,
paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null,
isRequested: !booking.trainScheduleId,
}
: null;
}

View File

@@ -291,7 +291,7 @@ describe('BookingClearanceService', () => {
const { service, bookingsRepository } = makeService({ booking: generalExportBooking });
const result = await service.uploadReleaseOrder(
'b-export',
{ fieldname: 'ro' } as Express.Multer.File,
[{ fieldname: 'ro' } as Express.Multer.File],
dateStr,
);

View File

@@ -1,6 +1,7 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import {
ContractDocPhase,
isDeliveryOrderFileCode,
isDraftDeclarationFileCode,
type ClearanceFinalInvoiceSummary,
type ClearanceOffloadState,
@@ -29,7 +30,7 @@ import { GlOperationsService } from './gl-operations.service';
import { GlExchangeService } from './gl-exchange.service';
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDraftDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
@@ -813,7 +814,7 @@ export class BookingClearanceService {
// GL Djibouti may have uploaded the DO early (un-gated) — count it now.
const files = await this.filesService.findByResource(bookingId, 'bookings');
if (files.some((f) => f.code === 'delivery_order')) {
if (files.some((f) => isDeliveryOrderFileCode(f.code))) {
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED');
await this.workflowService.markReadyForOperation(bookingId);
}
@@ -823,7 +824,7 @@ export class BookingClearanceService {
async uploadDeliveryOrder(
bookingId: string,
file: Express.Multer.File,
files: Express.Multer.File[],
userId?: string,
dates?: { vesselArrivalDate?: string; doCollectedDate?: string },
): Promise<Booking> {
@@ -832,19 +833,12 @@ export class BookingClearanceService {
throw new BadRequestException('Delivery Order applies only to import bookings.');
}
if (!file) throw new BadRequestException('No Delivery Order uploaded');
const { vesselArrivalDate, doCollectedDate } = assertDoCollectionDates(dates);
// DO upload is deliberately un-gated: GL Djibouti may attach it at any point,
// any file type. The DO_COLLECTED milestone (and operation readiness) still
// waits for GL Ethiopia to finalize pre-clearance so the workflow order holds.
await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
code: 'delivery_order',
file,
});
await persistDeliveryOrderUploads(this.filesService, bookingId, 'bookings', files ?? []);
await this.bookingsRepository.update(bookingId, {
vesselArrivalDate,
@@ -880,7 +874,7 @@ export class BookingClearanceService {
async uploadReleaseOrder(
bookingId: string,
file: Express.Multer.File,
files: Express.Multer.File[],
vesselDepartureDate: string,
userId?: string,
): Promise<{ booking: Booking; hold: boolean; holdReason?: string }> {
@@ -894,7 +888,6 @@ export class BookingClearanceService {
'RELEASE_ORDER_SECURED',
);
if (!file) throw new BadRequestException('No Release Order uploaded');
if (!vesselDepartureDate?.trim()) {
throw new BadRequestException('Vessel departure date is required');
}
@@ -902,12 +895,7 @@ export class BookingClearanceService {
const minDays = await this.resolveRoMinDays();
const leadDays = this.daysUntil(vesselDepartureDate);
await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
code: 'release_order',
file,
});
await persistReleaseOrderUploads(this.filesService, bookingId, 'bookings', files ?? []);
await this.bookingsRepository.update(bookingId, {
vesselDepartureDate,

View File

@@ -6,6 +6,7 @@ import {
} from '@nestjs/common';
import {
ContractDocPhase,
isDeliveryOrderFileCode,
type ClearanceFinalInvoiceSummary,
type ClearanceOffloadState,
type ClearanceSecondDuty,
@@ -36,7 +37,7 @@ import { Contract } from './entities/contract.entity';
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
import { FilterContractDto } from './dto/filter-contract.dto';
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
import { buildWorkflowFiles, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util';
import { buildWorkflowFiles, persistDeclarationUploads, persistDeliveryOrderUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util';
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
@@ -1449,7 +1450,7 @@ export class ContractClearanceService {
// GL Djibouti may have uploaded the DO early (un-gated) — count it now.
const files = await this.filesService.findByResource(contractId, 'contracts');
if (files.some((f) => f.code === 'delivery_order')) {
if (files.some((f) => isDeliveryOrderFileCode(f.code))) {
await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED');
await this.workflowService.markReadyForBooking(contractId);
}
@@ -1460,7 +1461,7 @@ export class ContractClearanceService {
async uploadDeliveryOrder(
contractId: string,
file: Express.Multer.File,
files: Express.Multer.File[],
userId?: string,
dates?: { vesselArrivalDate?: string; doCollectedDate?: string },
): Promise<Contract> {
@@ -1470,19 +1471,12 @@ export class ContractClearanceService {
throw new BadRequestException('Delivery Order applies only to import contracts.');
}
if (!file) throw new BadRequestException('No Delivery Order uploaded');
const { vesselArrivalDate, doCollectedDate } = assertDoCollectionDates(dates);
// DO upload is deliberately un-gated: GL Djibouti may attach it at any point,
// any file type. The DO_COLLECTED milestone (and booking readiness) still waits
// for GL Ethiopia to finalize pre-clearance so the workflow order holds.
await this.filesService.upsertByCode({
resourceId: contractId,
resource: 'contracts',
code: 'delivery_order',
file,
});
await persistDeliveryOrderUploads(this.filesService, contractId, 'contracts', files ?? []);
const cycle = await this.contractsRepository.currentCycle(contractId);
if (cycle) {
@@ -1520,7 +1514,7 @@ export class ContractClearanceService {
async uploadReleaseOrder(
contractId: string,
file: Express.Multer.File,
files: Express.Multer.File[],
vesselDepartureDate: string,
userId?: string,
): Promise<{ contract: Contract; hold: boolean; holdReason?: string }> {
@@ -1535,7 +1529,6 @@ export class ContractClearanceService {
'RELEASE_ORDER_SECURED',
);
if (!file) throw new BadRequestException('No Release Order uploaded');
if (!vesselDepartureDate?.trim()) {
throw new BadRequestException('Vessel departure date is required');
}
@@ -1545,12 +1538,7 @@ export class ContractClearanceService {
const cycle = await this.contractsRepository.currentCycle(contractId);
if (!cycle) throw new BadRequestException('No clearance cycle found');
await this.filesService.upsertByCode({
resourceId: contractId,
resource: 'contracts',
code: 'release_order',
file,
});
await persistReleaseOrderUploads(this.filesService, contractId, 'contracts', files ?? []);
await this.contractsRepository.updateCycle(cycle.id, {
vesselDepartureDate,

View File

@@ -956,20 +956,20 @@ export class ContractsController {
@Post(':id/clearance/delivery-order')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(FileInterceptor('file'))
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary:
'GL DJ uploads Delivery Order (import) with vessel arrival + DO collected dates',
'GL DJ uploads Delivery Order files (import) with vessel arrival + DO collected dates',
})
uploadDeliveryOrder(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@UploadedFiles() files: Express.Multer.File[],
@Body('vesselArrivalDate') vesselArrivalDate: string | undefined,
@Body('doCollectedDate') doCollectedDate: string | undefined,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.uploadDeliveryOrder(id, file, resolveAuthUserId(user), {
return this.clearanceService.uploadDeliveryOrder(id, files ?? [], resolveAuthUserId(user), {
vesselArrivalDate,
doCollectedDate,
});
@@ -977,18 +977,18 @@ export class ContractsController {
@Post(':id/clearance/release-order')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(FileInterceptor('file'))
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'GL DJ uploads Release Order + vessel departure date (export)' })
@ApiOperation({ summary: 'GL DJ uploads Release Order files + vessel departure date (export)' })
uploadReleaseOrder(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@UploadedFiles() files: Express.Multer.File[],
@Body('vesselDepartureDate') vesselDepartureDate: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.uploadReleaseOrder(
id,
file,
files ?? [],
vesselDepartureDate,
resolveAuthUserId(user),
);

View File

@@ -5,6 +5,10 @@ import {
draftDeclarationFileLabel,
isDeclarationFileCode,
isDraftDeclarationFileCode,
isDeliveryOrderFileCode,
isReleaseOrderFileCode,
deliveryOrderFileLabel,
releaseOrderFileLabel,
isImportTransitPermitFileCode,
isExportTransportFileCode,
isT1TransportFileCode,
@@ -166,6 +170,98 @@ export async function persistTransitPermitUploads(
);
}
/** Require at least one Delivery Order file in the upload batch. */
export function assertDeliveryOrderFiles(files: Express.Multer.File[]): void {
if (files.length === 0) {
throw new BadRequestException('No Delivery Order uploaded');
}
}
/** Assign stable `delivery_order_*` codes for multi-file DO uploads. */
export function normalizeDeliveryOrderFieldNames(
files: Express.Multer.File[],
): Express.Multer.File[] {
return files.map((file, index) => ({
...file,
fieldname: `delivery_order_${index}`,
}));
}
/** Replace all Delivery Order files on a resource with a new multi-file batch. */
export async function persistDeliveryOrderUploads(
store: DeclarationFileStore,
resourceId: string,
resource: string,
files: Express.Multer.File[],
): Promise<void> {
const normalized = normalizeDeliveryOrderFieldNames(files);
assertDeliveryOrderFiles(normalized);
const existing = await store.findByResource(resourceId, resource);
await Promise.all(
existing
.filter((f) => f.code && isDeliveryOrderFileCode(f.code))
.map((f) => store.deleteByCode(resourceId, resource, f.code!)),
);
await Promise.all(
normalized.map((file, index) =>
store.upload({
resourceId,
resource,
code: `delivery_order_${index}`,
file,
}),
),
);
}
/** Require at least one Release Order file in the upload batch. */
export function assertReleaseOrderFiles(files: Express.Multer.File[]): void {
if (files.length === 0) {
throw new BadRequestException('No Release Order uploaded');
}
}
/** Assign stable `release_order_*` codes for multi-file RO uploads. */
export function normalizeReleaseOrderFieldNames(
files: Express.Multer.File[],
): Express.Multer.File[] {
return files.map((file, index) => ({
...file,
fieldname: `release_order_${index}`,
}));
}
/** Replace all Release Order files on a resource with a new multi-file batch. */
export async function persistReleaseOrderUploads(
store: DeclarationFileStore,
resourceId: string,
resource: string,
files: Express.Multer.File[],
): Promise<void> {
const normalized = normalizeReleaseOrderFieldNames(files);
assertReleaseOrderFiles(normalized);
const existing = await store.findByResource(resourceId, resource);
await Promise.all(
existing
.filter((f) => f.code && isReleaseOrderFileCode(f.code))
.map((f) => store.deleteByCode(resourceId, resource, f.code!)),
);
await Promise.all(
normalized.map((file, index) =>
store.upload({
resourceId,
resource,
code: `release_order_${index}`,
file,
}),
),
);
}
/** Require at least one export transport document in the upload batch. */
export function assertExportTransportFiles(files: Express.Multer.File[]): void {
if (files.length === 0) {
@@ -422,6 +518,22 @@ export function buildWorkflowFiles(
});
});
const extraDeliveryOrders = files
.filter((f) => f.code && isDeliveryOrderFileCode(f.code) && !included.has(f.code))
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
extraDeliveryOrders.forEach((file, index) => {
if (!file.code) return;
included.add(file.code);
out.push({
code: file.code,
label: deliveryOrderFileLabel(file.code, index),
uploadedBy: 'gl_dj',
category: 'djibouti',
file: { id: file.id, name: file.name, url: file.url },
});
});
const extraT1 = files
.filter((f) => f.code && isT1TransportFileCode(f.code) && !included.has(f.code))
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
@@ -440,6 +552,22 @@ export function buildWorkflowFiles(
}
if (tradeDirection === 'EXPORT') {
const extraReleaseOrders = files
.filter((f) => f.code && isReleaseOrderFileCode(f.code) && !included.has(f.code))
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
extraReleaseOrders.forEach((file, index) => {
if (!file.code) return;
included.add(file.code);
out.push({
code: file.code,
label: releaseOrderFileLabel(file.code, index),
uploadedBy: 'gl_dj',
category: 'djibouti',
file: { id: file.id, name: file.name, url: file.url },
});
});
const extraExportTransport = files
.filter((f) => f.code && isExportTransportFileCode(f.code) && !included.has(f.code))
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));

View File

@@ -64,6 +64,14 @@ export class TrainSchedule extends BaseEntity {
@Column({ name: 'train_number', type: 'varchar', length: 20, nullable: true })
trainNumber?: string | null;
/**
* Voyage (sailing) number for this departure — the identifier yards and
* customs quote alongside the train number. Per-departure, so it lives here
* rather than on the built train.
*/
@Column({ name: 'voyage_number', type: 'varchar', length: 20, nullable: true })
voyageNumber?: string | null;
// Human-facing unique schedule reference (S-YYYY-NNNNN). Shown on the schedule
// list, booking windows, and load lists. Assigned at creation from the highest
// sequence issued this year (see TrainSchedulesRepository.maxReferenceSequence).

View File

@@ -15,6 +15,7 @@ import {
PortalCustomer,
TrainSchedulingCancel,
TrainSchedulingCreate,
TrainSchedulingEditTrainNumber,
TrainSchedulingReschedule,
TrainSchedulingRulesManage,
TrainSchedulingUpdate,
@@ -52,6 +53,8 @@ import { AvailableDaysForCargoQueryDto } from "../dto/available-days-for-cargo-q
import { UpdateTrainSchedulingGlobalRulesDto } from "../dto/update-train-scheduling-global-rules.dto";
import { UpdateScheduleWindowRuleDto } from "../dto/update-schedule-window-rule.dto";
import { UpdateScheduleDateDto } from "../dto/update-schedule-date.dto";
import { MergeScheduleTrainDto } from "../dto/merge-schedule-train.dto";
import { UpdateScheduleTrainNumberDto } from "../dto/update-schedule-train-number.dto";
import { MaintenanceRescheduleDto } from "../dto/maintenance-reschedule.dto";
import { TrainSchedulingService } from "../services/train-scheduling.service";
import { BookingBatchService } from "../booking-batch.service";
@@ -795,6 +798,47 @@ export class TrainSchedulingController {
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Patch("schedules/:id/train-number")
@TrainSchedulingEditTrainNumber()
@ApiOperation({
summary:
"Edit a departure's train number and voyage number — allowed only until the train is dispatched",
})
async updateScheduleTrainNumber(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateScheduleTrainNumberDto,
) {
await this.trainSchedulingService.updateScheduleTrainNumber(id, dto);
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Get("schedules/:id/merge-preview/:targetTrainId")
@TrainSchedulingUpdate()
@ApiOperation({
summary:
"What merging a train into this schedule would do — affected schedules, wagon totals and any blocking reasons. Read-only.",
})
async previewScheduleMerge(
@Param("id", ParseUUIDPipe) id: string,
@Param("targetTrainId", ParseUUIDPipe) targetTrainId: string,
) {
return this.trainSchedulingService.previewMerge(id, targetTrainId);
}
@Post("schedules/:id/merge")
@TrainSchedulingUpdate()
@ApiOperation({
summary:
"Merge another train into this schedule: its wagons join this consist, a same-day schedule on it is absorbed, and the emptied train is deactivated",
})
async mergeScheduleTrain(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: MergeScheduleTrainDto,
) {
await this.trainSchedulingService.mergeScheduleTrain(id, dto);
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Post("schedules/:id/maintenance")
@TrainSchedulingReschedule()
@ApiOperation({

View File

@@ -0,0 +1,23 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
/**
* Merge another train into this schedule's train. The schedule always survives:
* its train set is repointed at `targetTrainId`, that train's wagons join this
* consist, and the source train is left empty and deactivated.
*/
export class MergeScheduleTrainDto {
@ApiProperty({
description: "The train being merged IN. This schedule's train absorbs it.",
})
@IsUUID()
targetTrainId!: string;
@ApiPropertyOptional({
description: 'Why the trains were merged — kept on the audit trail.',
})
@IsOptional()
@IsString()
@MaxLength(500)
reason?: string;
}

View File

@@ -0,0 +1,40 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, MaxLength } from 'class-validator';
/**
* Edit a departure's operational run identifiers. Both fields are optional so
* either can be corrected alone; the service rejects a body carrying neither,
* so an empty request cannot write an audit row for a no-op.
*
* Sending an empty string clears the field; omitting it leaves it unchanged.
*/
export class UpdateScheduleTrainNumberDto {
@ApiPropertyOptional({
example: '9201',
description: "Run number for this departure. Empty string clears it.",
maxLength: 20,
})
@IsOptional()
@IsString()
@MaxLength(20)
trainNumber?: string;
@ApiPropertyOptional({
example: 'V-2026-014',
description: 'Voyage (sailing) number for this departure. Empty string clears it.',
maxLength: 20,
})
@IsOptional()
@IsString()
@MaxLength(20)
voyageNumber?: string;
@ApiPropertyOptional({
description: 'Why the numbers changed — kept on the audit trail.',
maxLength: 500,
})
@IsOptional()
@IsString()
@MaxLength(500)
reason?: string;
}

View File

@@ -0,0 +1,399 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { TrainSchedulingService } from './services/train-scheduling.service';
/**
* Merging one train into a schedule. The schedule ALWAYS survives: its train
* set is repointed at the target train, that train's wagons join the consist,
* a same-day schedule on the target is absorbed (bookings move here, it is
* soft-deleted), and the emptied source train is deactivated.
*
* Driven against stub repositories — every rule under test is service logic.
*/
describe('TrainSchedulingService — train merge', () => {
const DAY = '2026-08-12T00:00:00.000Z';
const OTHER_DAY = '2026-08-14T00:00:00.000Z';
/** Rows each repository returns, keyed by entity. */
type Fixture = {
schedule: Record<string, unknown> | null;
train?: Record<string, unknown> | null;
trainSets?: Record<string, unknown>[];
schedules?: Record<string, unknown>[];
wagons?: Record<string, unknown>[];
wagonTypes?: Record<string, unknown>[];
scheduleBookings?: Record<string, unknown>[];
allocations?: Record<string, unknown>[];
milestones?: Record<string, unknown>[];
setWagons?: Record<string, unknown>[];
};
const makeService = (fx: Fixture) => {
const updates: Array<{ entity: string; args: unknown[] }> = [];
const softDeletes: string[] = [];
const repoFor = (entity: unknown) => {
const name = (entity as { name?: string })?.name ?? String(entity);
const rows = (): Record<string, unknown>[] => {
switch (name) {
case 'Train':
return fx.train ? [fx.train] : [];
case 'TrainSet':
return fx.trainSets ?? [];
case 'TrainSchedule':
return fx.schedules ?? [];
case 'Wagon':
return fx.wagons ?? [];
case 'WagonType':
return fx.wagonTypes ?? [];
case 'TrainScheduleBooking':
return fx.scheduleBookings ?? [];
case 'WagonBookingAllocation':
return fx.allocations ?? [];
case 'RouteMilestone':
return fx.milestones ?? [];
case 'TrainSetWagon':
return fx.setWagons ?? [];
default:
return [];
}
};
return {
find: jest.fn().mockImplementation(async () => rows()),
findOne: jest.fn().mockImplementation(async () => rows()[0] ?? null),
update: jest.fn().mockImplementation(async (...args: unknown[]) => {
updates.push({ entity: name, args });
}),
softDelete: jest.fn().mockImplementation(async (id: string) => {
softDeletes.push(id);
}),
};
};
const dataSource = {
getRepository: jest.fn().mockImplementation(repoFor),
transaction: jest
.fn()
.mockImplementation(async (cb: (m: unknown) => Promise<void>) =>
cb({ getRepository: repoFor }),
),
};
const service = Object.create(
TrainSchedulingService.prototype,
) as TrainSchedulingService;
Object.assign(service, {
dataSource,
trainSchedulesRepository: {
findByIdWithFullGraph: jest.fn().mockResolvedValue(fx.schedule),
findById: jest.fn().mockResolvedValue(fx.schedule),
},
logger: { log: jest.fn(), warn: jest.fn() },
});
return { service, updates, softDeletes };
};
/** A draft schedule on T1 with 10 wagons and no locomotive caps. */
const baseSchedule = (over: Record<string, unknown> = {}) => ({
id: 'S1',
reference: 'S-2026-00001',
status: 'DRAFT',
scheduledDepartureDate: DAY,
routeId: null,
maxWagons: 0,
trainSetId: 'TS1',
trainSet: {
id: 'TS1',
trainId: 'T1',
wagons: Array.from({ length: 10 }, (_, i) => ({
id: `sw-${i}`,
sequenceNo: i + 1,
lengthMeters: 14,
wagonType: { tareWeightTons: 22.4 },
})),
},
...over,
});
const targetWagons = (n: number) =>
Array.from({ length: n }, (_, i) => ({
id: `w-${i}`,
wagonNumber: `200${i}`,
wagonTypeId: 'wt-1',
trainId: 'T2',
}));
describe('guards', () => {
it('refuses to merge into a dispatched schedule', async () => {
const { service } = makeService({
schedule: baseSchedule({ status: 'DISPATCHED' }),
});
await expect(
service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('refuses to merge a train into itself', async () => {
const { service } = makeService({ schedule: baseSchedule() });
await expect(
service.mergeScheduleTrain('S1', { targetTrainId: 'T1' }),
).rejects.toThrow(/already this schedule's train/i);
});
it('404s on an unknown schedule', async () => {
const { service } = makeService({ schedule: null });
await expect(
service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }),
).rejects.toBeInstanceOf(NotFoundException);
});
it('blocks when the target train has no wagons to give', async () => {
const { service } = makeService({
schedule: baseSchedule(),
train: { id: 'T2', code: 'TR-2' },
wagons: [],
});
await expect(
service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }),
).rejects.toThrow(/no wagons to merge/i);
});
});
describe('preview', () => {
it('reports the merged wagon total and the emptied source train', async () => {
const { service } = makeService({
schedule: baseSchedule(),
train: { id: 'T2', code: 'TR-2', trainNumber: '8002' },
wagons: targetWagons(40),
wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }],
});
const preview = await service.previewMerge('S1', 'T2');
expect(preview.canMerge).toBe(true);
expect(preview.wagons).toEqual({ current: 10, incoming: 40, merged: 50 });
expect(preview.sourceTrainWillDeactivate).toBe(true);
expect(preview.absorbedSchedule).toBeNull();
});
it('names the same-day schedule whose bookings move here', async () => {
const { service } = makeService({
schedule: baseSchedule(),
train: { id: 'T2', code: 'TR-2' },
trainSets: [{ id: 'TS2', trainId: 'T2' }],
schedules: [
{
id: 'S2',
reference: 'S-2026-00002',
status: 'SCHEDULED',
scheduledDepartureDate: DAY,
trainSetId: 'TS2',
},
],
wagons: targetWagons(40),
wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }],
scheduleBookings: [
{ id: 'sb-1', bookingId: 'bk-1', trainScheduleId: 'S2' },
{ id: 'sb-2', bookingId: 'bk-2', trainScheduleId: 'S2' },
],
});
const preview = await service.previewMerge('S1', 'T2');
expect(preview.absorbedSchedule).toMatchObject({
id: 'S2',
reference: 'S-2026-00002',
bookingsMoving: 2,
});
});
it('lists an other-day schedule as wagons-only, never absorbed', async () => {
const { service } = makeService({
schedule: baseSchedule(),
train: { id: 'T2', code: 'TR-2' },
trainSets: [{ id: 'TS2', trainId: 'T2' }],
schedules: [
{
id: 'S3',
reference: 'S-2026-00003',
status: 'DRAFT',
scheduledDepartureDate: OTHER_DAY,
trainSetId: 'TS2',
},
],
wagons: targetWagons(40),
wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }],
});
const preview = await service.previewMerge('S1', 'T2');
expect(preview.absorbedSchedule).toBeNull();
expect(preview.affectedSchedules).toHaveLength(1);
expect(preview.affectedSchedules[0]).toMatchObject({ id: 'S3' });
});
it('leaves a dispatched schedule on the target untouched', async () => {
const { service } = makeService({
schedule: baseSchedule(),
train: { id: 'T2', code: 'TR-2' },
trainSets: [{ id: 'TS2', trainId: 'T2' }],
schedules: [
{
id: 'S4',
status: 'DISPATCHED',
scheduledDepartureDate: DAY,
trainSetId: 'TS2',
},
],
wagons: targetWagons(40),
wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }],
});
const preview = await service.previewMerge('S1', 'T2');
// Same day, but dispatched — its cargo stays put.
expect(preview.absorbedSchedule).toBeNull();
expect(preview.affectedSchedules).toHaveLength(0);
expect(preview.untouchedSchedules).toHaveLength(1);
});
});
describe('commit', () => {
it('repoints the set, moves the wagons and deactivates the source train', async () => {
const { service, updates } = makeService({
schedule: baseSchedule(),
train: { id: 'T2', code: 'TR-2' },
wagons: targetWagons(40),
wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }],
});
await service.mergeScheduleTrain('S1', { targetTrainId: 'T2' });
const setRepoint = updates.find(
(u) => u.entity === 'TrainSet' && u.args[0] === 'TS1',
);
expect(setRepoint?.args[1]).toMatchObject({ trainId: 'T2' });
const wagonMove = updates.find((u) => u.entity === 'Wagon');
expect(wagonMove?.args[1]).toMatchObject({ trainId: 'T2' });
const trainPark = updates.find(
(u) => u.entity === 'Train' && u.args[0] === 'T1',
);
expect(trainPark?.args[1]).toMatchObject({ status: 'DEACTIVATED' });
});
it('moves the absorbed schedule\'s bookings here and soft-deletes it', async () => {
const { service, updates, softDeletes } = makeService({
schedule: baseSchedule(),
train: { id: 'T2', code: 'TR-2' },
trainSets: [{ id: 'TS2', trainId: 'T2' }],
schedules: [
{
id: 'S2',
reference: 'S-2026-00002',
status: 'SCHEDULED',
scheduledDepartureDate: DAY,
trainSetId: 'TS2',
},
],
wagons: targetWagons(40),
wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }],
scheduleBookings: [
{ id: 'sb-1', bookingId: 'bk-1', trainScheduleId: 'S2' },
],
});
await service.mergeScheduleTrain('S1', { targetTrainId: 'T2' });
const bookingMove = updates.find(
(u) => u.entity === 'TrainScheduleBooking',
);
expect(bookingMove?.args[0]).toMatchObject({ trainScheduleId: 'S2' });
expect(bookingMove?.args[1]).toMatchObject({ trainScheduleId: 'S1' });
// Soft-deleted, not cancelled — the bookings still exist and still depart.
expect(softDeletes).toEqual(['S2']);
});
it('appends merged wagons after the existing consist', async () => {
const { service, updates } = makeService({
schedule: baseSchedule(),
train: { id: 'T2', code: 'TR-2' },
wagons: targetWagons(2),
wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }],
setWagons: [
{ id: 'in-0', trainSetId: 'TS2', physicalWagonId: 'w-0' },
{ id: 'in-1', trainSetId: 'TS2', physicalWagonId: 'w-1' },
],
});
await service.mergeScheduleTrain('S1', { targetTrainId: 'T2' });
// 10 existing wagons occupy 1..10, so the merged pair lands at 11 and 12
// — staff reorder them in the train builder afterwards.
const seqs = updates
.filter((u) => u.entity === 'TrainSetWagon')
.map((u) => (u.args[1] as { sequenceNo: number }).sequenceNo);
expect(seqs).toEqual([11, 12]);
});
});
describe('capacity', () => {
it('blocks a merge that overruns the locomotive length cap', async () => {
const { service } = makeService({
schedule: baseSchedule({
trainSet: {
id: 'TS1',
trainId: 'T1',
// A short loco: 100m of train, already 10 × 14m = 140m used.
locomotive: {
maxPullWeightTons: 5000,
maxTrainLengthMeters: 100,
},
wagons: Array.from({ length: 10 }, (_, i) => ({
id: `sw-${i}`,
sequenceNo: i + 1,
lengthMeters: 14,
wagonType: { tareWeightTons: 22.4 },
})),
},
}),
train: { id: 'T2', code: 'TR-2' },
wagons: targetWagons(40),
wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }],
});
await expect(
service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }),
).rejects.toThrow(/exceeds max train length/i);
});
it('blocks a merge that overruns the pull-weight cap', async () => {
const { service } = makeService({
schedule: baseSchedule({
trainSet: {
id: 'TS1',
trainId: 'T1',
locomotive: {
maxPullWeightTons: 300,
maxTrainLengthMeters: 10000,
},
wagons: [],
},
}),
train: { id: 'T2', code: 'TR-2' },
wagons: targetWagons(40),
wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }],
});
await expect(
service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }),
).rejects.toThrow(/exceeds max pull weight/i);
});
});
});

View File

@@ -0,0 +1,114 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { TrainSchedulingService } from './services/train-scheduling.service';
import type { UpdateScheduleTrainNumberDto } from './dto/update-schedule-train-number.dto';
/**
* Guards around renumbering a departure. Exercised against a stub repository —
* the rules (dispatch lock, empty-body rejection, clear-vs-leave semantics) are
* pure service logic and need no database.
*/
describe('TrainSchedulingService.updateScheduleTrainNumber', () => {
const makeService = (schedule: Record<string, unknown> | null) => {
const update = jest.fn().mockResolvedValue(undefined);
const findById = jest.fn().mockResolvedValue(schedule);
const service = Object.create(
TrainSchedulingService.prototype,
) as TrainSchedulingService;
Object.assign(service, {
trainSchedulesRepository: { findById, update },
logger: { log: jest.fn(), warn: jest.fn() },
});
return { service, update, findById };
};
const call = (service: TrainSchedulingService, dto: UpdateScheduleTrainNumberDto) =>
service.updateScheduleTrainNumber('sched-1', dto);
it('updates both numbers on a SCHEDULED train', async () => {
const { service, update } = makeService({
id: 'sched-1',
status: 'SCHEDULED',
trainNumber: '9101',
voyageNumber: null,
});
await call(service, { trainNumber: '9201', voyageNumber: 'V-2026-014' });
expect(update).toHaveBeenCalledWith('sched-1', {
trainNumber: '9201',
voyageNumber: 'V-2026-014',
});
});
it('refuses to renumber a dispatched train', async () => {
// The numbers are already printed on paperwork that left with the train.
const { service, update } = makeService({
id: 'sched-1',
status: 'DISPATCHED',
trainNumber: '9101',
});
await expect(call(service, { trainNumber: '9201' })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(update).not.toHaveBeenCalled();
});
it.each(['ARRIVED', 'CANCELLED', 'COMPLETED'])(
'refuses to renumber a %s schedule',
async (status) => {
const { service, update } = makeService({ id: 'sched-1', status });
await expect(call(service, { trainNumber: '9201' })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(update).not.toHaveBeenCalled();
},
);
it('rejects a body carrying neither number before touching the schedule', async () => {
const { service, update, findById } = makeService({
id: 'sched-1',
status: 'DRAFT',
});
await expect(call(service, {})).rejects.toBeInstanceOf(BadRequestException);
expect(findById).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
});
it('leaves an omitted field untouched rather than clearing it', async () => {
const { service, update } = makeService({
id: 'sched-1',
status: 'DRAFT',
trainNumber: '9101',
voyageNumber: 'V-1',
});
await call(service, { trainNumber: '9201' });
expect(update).toHaveBeenCalledWith('sched-1', { trainNumber: '9201' });
expect(update.mock.calls[0][1]).not.toHaveProperty('voyageNumber');
});
it('clears a field when an empty string is sent', async () => {
const { service, update } = makeService({
id: 'sched-1',
status: 'DRAFT',
voyageNumber: 'V-1',
});
await call(service, { voyageNumber: ' ' });
expect(update).toHaveBeenCalledWith('sched-1', { voyageNumber: null });
});
it('404s on an unknown schedule', async () => {
const { service } = makeService(null);
await expect(call(service, { trainNumber: '9201' })).rejects.toBeInstanceOf(
NotFoundException,
);
});
});

View File

@@ -96,6 +96,8 @@ import {
} from '../dto/import-djibouti-operation.dto';
import { UpdateScheduleWindowRuleDto } from '../dto/update-schedule-window-rule.dto';
import { UpdateScheduleDateDto } from '../dto/update-schedule-date.dto';
import { MergeScheduleTrainDto } from '../dto/merge-schedule-train.dto';
import { UpdateScheduleTrainNumberDto } from '../dto/update-schedule-train-number.dto';
import { MaintenanceRescheduleDto } from '../dto/maintenance-reschedule.dto';
import { type BookingWindowConfig } from '../booking-window.config';
import { BookingWindowGateway } from '../booking-window.gateway';
@@ -132,14 +134,17 @@ import {
} from '../utils/wagon-plan.util';
import { CorridorBudget } from '../corridor-capacity.util';
import { deriveScheduleDirection } from '../utils/derive-schedule-direction.util';
import { computeScheduleWagonUsage } from '../utils/schedule-wagon-usage.util';
import { pickLowestFreeNumber, pickTrainNumberPool } from '../train-number.util';
import {
bookingCargoTons,
bulkItemsFitFor,
bulkItemWagonsRequired,
bulkTonsPerWagon,
consistViolations,
deriveTrainCapacityFromLocomotive,
combinedLocomotiveLimits,
trainHardCaps,
trainSetLocomotiveLimits,
wagonTypeDimensionsFromEntity,
LocomotiveLimits,
@@ -928,6 +933,68 @@ export class TrainSchedulingService {
return fresh ?? schedule;
}
/**
* Correct a departure's operational run identifiers — the train number and
* voyage number yards and customs quote.
*
* Editable only until the train leaves: once DISPATCHED (or beyond) the
* numbers are printed on paperwork and quoted downstream, so a late edit would
* desync records that already left with the train. The audit row is written by
* the global AuditInterceptor from the registered route.
*/
async updateScheduleTrainNumber(
id: string,
dto: UpdateScheduleTrainNumberDto,
): Promise<TrainSchedule> {
if (dto.trainNumber === undefined && dto.voyageNumber === undefined) {
throw new BadRequestException(
'Provide a train number or a voyage number to update.',
);
}
const schedule = await this.trainSchedulesRepository.findById(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
// Only a train that has not left can be renumbered. CANCELLED is excluded
// too — renumbering a dead schedule has no meaning.
const editable: string[] = [
TrainScheduleStatusEnum.Draft,
TrainScheduleStatusEnum.Scheduled,
];
if (!editable.includes(schedule.status)) {
throw new BadRequestException(
`Cannot change the train or voyage number of a ${schedule.status} schedule — ` +
'the numbers are fixed once the train is dispatched.',
);
}
// An empty string clears the field; an omitted field is left untouched.
const patch: Partial<TrainSchedule> = {};
if (dto.trainNumber !== undefined) {
patch.trainNumber = dto.trainNumber.trim() || null;
}
if (dto.voyageNumber !== undefined) {
patch.voyageNumber = dto.voyageNumber.trim() || null;
}
await this.trainSchedulesRepository.update(id, patch);
this.logger.log(
`Schedule ${schedule.reference ?? id} renumbered` +
(patch.trainNumber !== undefined
? ` — train ${schedule.trainNumber ?? '—'}${patch.trainNumber ?? '—'}`
: '') +
(patch.voyageNumber !== undefined
? ` — voyage ${schedule.voyageNumber ?? '—'}${patch.voyageNumber ?? '—'}`
: '') +
(dto.reason?.trim() ? ` (${dto.reason.trim()})` : ''),
);
const fresh = await this.trainSchedulesRepository.findById(id);
return fresh ?? schedule;
}
/**
* Reschedule ONE train's departure date (staff action on the ops board). Only
* allowed while the booking window has not opened yet — an OPEN/past schedule
@@ -4072,7 +4139,15 @@ export class TrainSchedulingService {
const [schedules, total] = await this.trainSchedulesRepository.findAndCount({
where,
relations: {
trainSet: { locomotive: true, locomotives: { locomotive: true }, train: true },
trainSet: {
locomotive: true,
locomotives: { locomotive: true },
train: true,
// Slot allocations back the list's "used wagons" figure — without
// them the row can only report the coupled consist size, which is
// what made the list disagree with the detail page's wagon plan.
wagons: { allocations: true },
},
// Yards carry the route's display name used by mapScheduleListItem;
// milestones (with yards) let it show the full corridor path.
route: { originYard: true, destinationYard: true, milestones: { yard: true } },
@@ -5752,12 +5827,22 @@ export class TrainSchedulingService {
}
private mapScheduleListItem(schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule) {
// Wagon figures must match the detail page's wagon plan (WagonPlanGrid) —
// see computeScheduleWagonUsage for why the stored counter cannot be used.
const { wagonsUsed, wagonsTotal, wagonsReserved, wagonsRemaining } =
computeScheduleWagonUsage({
wagonSlots: schedule.trainSet?.wagons,
storedWagonCount: schedule.trainSet?.wagonCount,
scheduleBookings: schedule.scheduleBookings,
});
return {
id: schedule.id,
reference: schedule.reference ?? null,
createdAt: schedule.createdAt ?? null,
scheduleDate: schedule.scheduledDepartureDate,
trainNumber: schedule.trainNumber ?? null,
voyageNumber: schedule.voyageNumber ?? null,
direction: schedule.direction ?? null,
routeName: schedule.route ? formatRouteLabel(schedule.route) : null,
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
@@ -5786,6 +5871,14 @@ export class TrainSchedulingService {
currentYardId: loco.currentYardId ?? null,
})),
wagonCount: schedule.trainSet?.wagonCount ?? 0,
/** Coupled slots carrying a booking allocation — matches the wagon plan. */
wagonsUsed,
/** Coupled consist size; the denominator of "used". */
wagonsTotal,
/** Claimed by bookings (incl. unpaid) — not bookable. */
wagonsReserved,
/** Consist minus what bookings have claimed; what is still bookable. */
wagonsRemaining,
totalWeightTons: roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)),
totalLengthMeters: roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)),
bookingsCount: schedule.scheduleBookings?.length ?? 0,
@@ -7703,6 +7796,7 @@ export class TrainSchedulingService {
status: schedule.status,
freightType: this.resolveScheduleFreightType(schedule),
trainNumber: schedule.trainNumber ?? null,
voyageNumber: schedule.voyageNumber ?? null,
maxWagons: schedule.maxWagons ?? null,
direction: schedule.direction ?? null,
reverseWagonOrder: schedule.reverseWagonOrder ?? false,
@@ -9122,4 +9216,376 @@ export class TrainSchedulingService {
});
return new Set(allocations.map((a) => a.bookingId));
}
// ── Train merge ────────────────────────────────────────────────────────────
// Combine two trains into one departure. The schedule the action is taken
// from ALWAYS survives: its train set is repointed at the target train, the
// target's wagons join this consist, and the source train is emptied and
// deactivated. When the target also runs a schedule on the SAME DAY, that
// schedule's bookings move here and it is soft-deleted; the target's
// other-day schedules contribute wagons only.
/** Statuses whose schedules may take part in a merge. */
private static readonly MERGEABLE_STATUSES: string[] = [
TrainScheduleStatusEnum.Draft,
TrainScheduleStatusEnum.Scheduled,
];
/**
* Everything a merge needs to decide, gathered once. Both `previewMerge` and
* `mergeScheduleTrain` run this so the modal shows exactly what will happen
* and the commit cannot diverge from it.
*/
private async planMerge(scheduleId: string, targetTrainId: string) {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!TrainSchedulingService.MERGEABLE_STATUSES.includes(schedule.status)) {
throw new BadRequestException(
`Cannot merge into a ${schedule.status} schedule — only draft or scheduled departures can be merged.`,
);
}
const sourceTrainId = schedule.trainSet?.trainId ?? null;
if (sourceTrainId && sourceTrainId === targetTrainId) {
throw new BadRequestException(
'That is already this schedule\'s train — pick a different one to merge in.',
);
}
const targetTrain = await this.dataSource
.getRepository(Train)
.findOne({ where: { id: targetTrainId } });
if (!targetTrain) {
throw new NotFoundException(`Train ${targetTrainId} not found`);
}
// Every schedule the target train is committed to, via its train sets.
const targetSets = await this.dataSource
.getRepository(TrainSet)
.find({ where: { trainId: targetTrainId } });
const targetSetIds = targetSets.map((s) => s.id);
const targetSchedules = targetSetIds.length
? await this.dataSource.getRepository(TrainSchedule).find({
where: { trainSetId: In(targetSetIds) },
})
: [];
// The same-day schedule is the one whose bookings move here. Only a
// draft/scheduled one qualifies — a dispatched departure keeps its cargo.
const sameDay = (a: Date | string, b: Date | string) =>
new Date(a).toISOString().slice(0, 10) ===
new Date(b).toISOString().slice(0, 10);
const absorbed =
targetSchedules.find(
(s) =>
s.id !== schedule.id &&
sameDay(s.scheduledDepartureDate, schedule.scheduledDepartureDate) &&
TrainSchedulingService.MERGEABLE_STATUSES.includes(s.status),
) ?? null;
// Wagons ride with the train, so every OTHER draft/scheduled schedule on it
// is affected too — it gains the merged consist but never the bookings.
const affectedOthers = targetSchedules.filter(
(s) =>
s.id !== schedule.id &&
s.id !== absorbed?.id &&
TrainSchedulingService.MERGEABLE_STATUSES.includes(s.status),
);
const untouched = targetSchedules.filter(
(s) =>
s.id !== schedule.id &&
s.id !== absorbed?.id &&
!TrainSchedulingService.MERGEABLE_STATUSES.includes(s.status),
);
// The wagons joining this consist: whatever physically sits on the target
// train today.
const incomingWagons = await this.dataSource
.getRepository(Wagon)
.find({ where: { trainId: targetTrainId }, order: { wagonNumber: 'ASC' } });
const movingBookings = absorbed
? await this.dataSource.getRepository(TrainScheduleBooking).find({
where: { trainScheduleId: absorbed.id },
relations: { booking: true },
})
: [];
return {
schedule,
sourceTrainId,
targetTrain,
absorbed,
affectedOthers,
untouched,
incomingWagons,
movingBookings,
};
}
/**
* Blocking checks, run against the plan. Returns human-readable reasons; an
* empty array means the merge may proceed. Kept separate from `planMerge` so
* the preview can SHOW the reasons rather than throwing on them.
*/
private async mergeBlockers(
plan: Awaited<ReturnType<TrainSchedulingService['planMerge']>>,
): Promise<string[]> {
const blockers: string[] = [];
const { schedule, incomingWagons, movingBookings, absorbed } = plan;
if (incomingWagons.length === 0) {
blockers.push(
`${plan.targetTrain.code} has no wagons to merge — nothing would move.`,
);
}
// ── Capacity: the merged consist must fit this schedule's locomotives ────
const existingSlots = (schedule.trainSet?.wagons ?? []).map((w) => ({
lengthMeters: Number(w.lengthMeters) || 0,
tareWeightTons: Number(w.wagonType?.tareWeightTons) || 0,
cargoTons: 0,
}));
const wagonTypeIds = [
...new Set(incomingWagons.map((w) => w.wagonTypeId).filter(Boolean)),
];
const wagonTypes = wagonTypeIds.length
? await this.dataSource
.getRepository(WagonType)
.find({ where: { id: In(wagonTypeIds) } })
: [];
const typeById = new Map(wagonTypes.map((t) => [t.id, t]));
const incomingSlots = incomingWagons.map((w) => {
const t = typeById.get(w.wagonTypeId);
return {
lengthMeters: Number(t?.lengthMeters) || 0,
tareWeightTons: Number(t?.tareWeightTons) || 0,
cargoTons: 0,
};
});
const limits = trainSetLocomotiveLimits(schedule.trainSet);
if (limits) {
const rules = await this.dataSource
.getRepository(TrainSchedulingGlobalRules)
.find({ take: 1 });
const caps = trainHardCaps(limits, {
maxTrainWeightTons: rules[0]?.maxTrainWeightTons ?? undefined,
maxTrainLengthMeters: rules[0]?.maxTrainLengthMeters ?? undefined,
});
const merged = [...existingSlots, ...incomingSlots];
// maxWagons is the schedule's own slot ceiling; fall back to the consist
// size when it is unset so the count axis never blocks spuriously.
const violations = consistViolations(merged, {
maxWeightTons: caps.maxWeightTons,
maxLengthMeters: caps.maxLengthMeters,
maxWagonSlots: schedule.maxWagons || merged.length,
});
blockers.push(...violations);
}
// ── Legs: an absorbed booking must be servable by THIS schedule's route ──
if (absorbed && movingBookings.length) {
const routeYardIds = await this.routeYardSequence(schedule.routeId ?? null);
if (routeYardIds.length) {
const position = new Map(routeYardIds.map((id, i) => [id, i]));
const slotIds = movingBookings.map((mb) => mb.bookingId);
const allocations = slotIds.length
? await this.dataSource.getRepository(WagonBookingAllocation).find({
where: { bookingId: In(slotIds) },
relations: { trainSetWagon: true },
})
: [];
const offRoute = new Set<string>();
for (const alloc of allocations) {
const board = alloc.trainSetWagon?.boardYardId ?? null;
const alight = alloc.trainSetWagon?.alightYardId ?? null;
// Null on both = rides the whole route; always compatible.
if (!board && !alight) continue;
const from = board ? position.get(board) : 0;
const to = alight ? position.get(alight) : routeYardIds.length - 1;
if (from === undefined || to === undefined || from >= to) {
offRoute.add(alloc.bookingId);
}
}
if (offRoute.size) {
blockers.push(
`${offRoute.size} booking(s) on ${absorbed.reference ?? 'the merged schedule'} ` +
'travel legs this schedule\'s route does not serve in the same order.',
);
}
}
}
return blockers;
}
/** Ordered yard ids along a route, origin first. Empty when unknown. */
private async routeYardSequence(routeId: string | null): Promise<string[]> {
if (!routeId) return [];
const milestones = await this.dataSource
.getRepository(RouteMilestone)
.find({ where: { routeId }, order: { sequenceNo: 'ASC' } });
return milestones
.map((m) => m.yardId)
.filter((id): id is string => Boolean(id));
}
/**
* What a merge WOULD do, without doing it. Drives the confirmation modal:
* which schedules gain wagons, which one is absorbed, and why it is blocked.
*/
async previewMerge(scheduleId: string, targetTrainId: string) {
const plan = await this.planMerge(scheduleId, targetTrainId);
const blockers = await this.mergeBlockers(plan);
const existingCount = plan.schedule.trainSet?.wagons?.length ?? 0;
return {
canMerge: blockers.length === 0,
blockers,
targetTrain: {
id: plan.targetTrain.id,
code: plan.targetTrain.code,
trainNumber: plan.targetTrain.trainNumber ?? null,
},
wagons: {
current: existingCount,
incoming: plan.incomingWagons.length,
merged: existingCount + plan.incomingWagons.length,
},
/** The same-day schedule whose bookings move here and is then removed. */
absorbedSchedule: plan.absorbed
? {
id: plan.absorbed.id,
reference: plan.absorbed.reference ?? null,
scheduledDepartureDate: plan.absorbed.scheduledDepartureDate,
status: plan.absorbed.status,
bookingsMoving: plan.movingBookings.length,
}
: null,
/** Other draft/scheduled schedules on the target — wagons only. */
affectedSchedules: plan.affectedOthers.map((s) => ({
id: s.id,
reference: s.reference ?? null,
scheduledDepartureDate: s.scheduledDepartureDate,
status: s.status,
})),
/** On the target train but left alone (dispatched, cancelled, …). */
untouchedSchedules: plan.untouched.map((s) => ({
id: s.id,
reference: s.reference ?? null,
scheduledDepartureDate: s.scheduledDepartureDate,
status: s.status,
})),
sourceTrainWillDeactivate: Boolean(plan.sourceTrainId),
};
}
/**
* Execute the merge. One transaction: repoint the train set, move the wagons
* (appended last so the builder can reorder them later), carry the absorbed
* schedule's bookings across, soft-delete that schedule, and deactivate the
* emptied source train.
*/
async mergeScheduleTrain(
scheduleId: string,
dto: MergeScheduleTrainDto,
): Promise<TrainSchedule> {
const plan = await this.planMerge(scheduleId, dto.targetTrainId);
const blockers = await this.mergeBlockers(plan);
if (blockers.length) {
throw new BadRequestException(blockers.join(' '));
}
const {
schedule,
sourceTrainId,
targetTrain,
absorbed,
incomingWagons,
movingBookings,
} = plan;
const trainSetId = schedule.trainSetId;
await this.dataSource.transaction(async (manager) => {
// 1. This schedule's set now runs on the target train.
await manager.getRepository(TrainSet).update(trainSetId, {
trainId: targetTrain.id,
});
// 2. The physical wagons follow the train.
if (incomingWagons.length) {
await manager.getRepository(Wagon).update(
{ id: In(incomingWagons.map((w) => w.id)) },
{ trainId: targetTrain.id },
);
}
// 3. Carry the target's train-set wagon rows into THIS consist, appended
// after the existing wagons. Sequence is provisional — staff reorder
// in the train builder afterwards.
const existing = schedule.trainSet?.wagons ?? [];
let nextSequence =
existing.reduce((max, w) => Math.max(max, w.sequenceNo ?? 0), 0) + 1;
const incomingSetWagons = await manager.getRepository(TrainSetWagon).find({
where: { physicalWagonId: In(incomingWagons.map((w) => w.id)) },
});
for (const row of incomingSetWagons) {
if (row.trainSetId === trainSetId) continue;
await manager.getRepository(TrainSetWagon).update(row.id, {
trainSetId,
sequenceNo: nextSequence,
});
nextSequence += 1;
}
// 4. The absorbed schedule's bookings move here. `bookingId` is uniquely
// indexed, so these rows are UPDATED across rather than re-inserted.
if (absorbed && movingBookings.length) {
await manager
.getRepository(TrainScheduleBooking)
.update(
{ trainScheduleId: absorbed.id },
{ trainScheduleId: schedule.id },
);
}
// 5. The absorbed schedule is soft-deleted — its bookings still exist and
// still depart that day, so nobody is notified and nothing is lost.
if (absorbed) {
await manager.getRepository(TrainSchedule).softDelete(absorbed.id);
}
// 6. The source train is now empty; park it.
if (sourceTrainId) {
await manager.getRepository(Train).update(sourceTrainId, {
status: Freight.TrainStatus.Deactivated,
});
}
// 7. Keep the set's cached totals honest.
const mergedCount =
(schedule.trainSet?.wagons?.length ?? 0) + incomingSetWagons.length;
await manager
.getRepository(TrainSet)
.update(trainSetId, { wagonCount: mergedCount });
});
this.logger.log(
`Schedule ${schedule.reference ?? scheduleId} merged with train ${targetTrain.code}` +
`${incomingWagons.length} wagon(s) moved` +
(absorbed
? `, absorbed ${absorbed.reference ?? absorbed.id} (${movingBookings.length} booking(s))`
: '') +
(sourceTrainId ? ', source train deactivated' : '') +
(dto.reason?.trim() ? ` (${dto.reason.trim()})` : ''),
);
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
return fresh ?? schedule;
}
}

View File

@@ -0,0 +1,96 @@
import { computeScheduleWagonUsage } from './schedule-wagon-usage.util';
/** A coupled slot; `allocated` = a booking actually sits on it. */
const slot = (allocated = false) => ({ allocations: allocated ? [{}] : [] });
const booking = (wagonsRequired: number | null) => ({ booking: { wagonsRequired } });
describe('computeScheduleWagonUsage', () => {
it('reports allocated slots as used, not the coupled consist size', () => {
// The reported bug: a 37-wagon consist carrying 3 allocated bookings read
// "37 wgn used" in the list while the detail page read "3 in use".
const slots = [...Array(34).fill(slot(false)), ...Array(3).fill(slot(true))];
const usage = computeScheduleWagonUsage({
wagonSlots: slots,
storedWagonCount: 37,
scheduleBookings: [],
});
expect(usage.wagonsUsed).toBe(3);
expect(usage.wagonsTotal).toBe(37);
});
it('counts a built train with no bookings as 0 used', () => {
const usage = computeScheduleWagonUsage({
wagonSlots: Array(40).fill(slot(false)),
storedWagonCount: 40,
scheduleBookings: [],
});
expect(usage.wagonsUsed).toBe(0);
expect(usage.wagonsRemaining).toBe(40);
});
it('treats wagons of an unpaid booking as reserved, so they are not bookable', () => {
// Booking claims 5 wagons but has no wagon plan yet: 0 used, still only 5
// bookable on a 10-wagon train — the reservation is not free space.
const usage = computeScheduleWagonUsage({
wagonSlots: Array(10).fill(slot(false)),
storedWagonCount: 10,
scheduleBookings: [booking(5)],
});
expect(usage.wagonsUsed).toBe(0);
expect(usage.wagonsReserved).toBe(5);
expect(usage.wagonsRemaining).toBe(5);
});
it('does not double-count a booking that is both reserved and allocated', () => {
// 3 allocated slots for a booking that reserved 3 wagons: 7 remain, not 4.
const usage = computeScheduleWagonUsage({
wagonSlots: [...Array(7).fill(slot(false)), ...Array(3).fill(slot(true))],
storedWagonCount: 10,
scheduleBookings: [booking(3)],
});
expect(usage.wagonsUsed).toBe(3);
expect(usage.wagonsReserved).toBe(3);
expect(usage.wagonsRemaining).toBe(7);
});
it('never reports negative remaining when claims exceed the consist', () => {
const usage = computeScheduleWagonUsage({
wagonSlots: Array(2).fill(slot(false)),
storedWagonCount: 2,
scheduleBookings: [booking(5)],
});
expect(usage.wagonsRemaining).toBe(0);
});
it('falls back to the stored counter when slot rows were not loaded', () => {
const usage = computeScheduleWagonUsage({
wagonSlots: [],
storedWagonCount: 12,
scheduleBookings: [],
});
expect(usage.wagonsTotal).toBe(12);
expect(usage.wagonsUsed).toBe(0);
});
it('tolerates missing relations and null wagonsRequired', () => {
const usage = computeScheduleWagonUsage({
wagonSlots: null,
storedWagonCount: null,
scheduleBookings: [booking(null)],
});
expect(usage).toEqual({
wagonsUsed: 0,
wagonsTotal: 0,
wagonsReserved: 0,
wagonsRemaining: 0,
});
});
});

View File

@@ -0,0 +1,58 @@
/**
* Wagon figures for a train-schedule list row.
*
* The list used to report `trainSet.wagonCount` — the COUPLED CONSIST SIZE —
* under the label "wgn used", so a 37-wagon train carrying 3 allocated bookings
* read "37 wgn used" in the list while its detail page (WagonPlanGrid) read
* "37 wagons · 3 in use". These helpers make the list agree with the detail
* page, which is the figure staff trust.
*/
/** The shape this math needs — a slot counts as used when it has allocations. */
export interface WagonSlotLike {
allocations?: unknown[] | null;
}
export interface ScheduleBookingLike {
booking?: { wagonsRequired?: number | null } | null;
}
export interface ScheduleWagonUsage {
/** Coupled slots carrying at least one booking allocation. */
wagonsUsed: number;
/** Coupled consist size — the denominator of `wagonsUsed`. */
wagonsTotal: number;
/** Wagons claimed by bookings, including bookings that have not paid. */
wagonsReserved: number;
/** Consist minus what bookings have claimed — what is still bookable. */
wagonsRemaining: number;
}
export function computeScheduleWagonUsage(input: {
wagonSlots?: WagonSlotLike[] | null;
/** Stored counter; used only when the slot rows were not loaded. */
storedWagonCount?: number | null;
scheduleBookings?: ScheduleBookingLike[] | null;
}): ScheduleWagonUsage {
const slots = input.wagonSlots ?? [];
// Same predicate as the detail page's WagonPlanGrid: a slot is in use only
// when a booking is actually allocated onto it.
const wagonsUsed = slots.filter((slot) => (slot.allocations?.length ?? 0) > 0).length;
// Prefer live slot rows; the stored counter drifts when a consist is edited
// without a recompute, which is why the list and detail disagreed on totals.
const wagonsTotal = slots.length || (input.storedWagonCount ?? 0);
// An unpaid booking still holds its wagons, so reserved space is NOT bookable.
const wagonsReserved = (input.scheduleBookings ?? []).reduce(
(sum, link) => sum + (link.booking?.wagonsRequired ?? 0),
0,
);
// Reserved subsumes allocated — an allocated booking still counts its wagons —
// so remaining subtracts whichever claim is larger, never both.
const wagonsRemaining = Math.max(0, wagonsTotal - Math.max(wagonsUsed, wagonsReserved));
return { wagonsUsed, wagonsTotal, wagonsReserved, wagonsRemaining };
}

View File

@@ -51,9 +51,9 @@ export class BuildTrainDto {
@IsUUID('all', { each: true })
wagonIds?: string[];
@ApiProperty({ maxLength: 100, description: 'Vogue number' })
@ApiProperty({ maxLength: 100, description: 'Voyage number' })
@IsString()
@IsNotEmpty({ message: 'Vogue number is required' })
@IsNotEmpty({ message: 'Voyage number is required' })
@MaxLength(100)
trainName!: string;

View File

@@ -0,0 +1,15 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, MaxLength } from 'class-validator';
export class SendWagonToMaintenanceDto {
@ApiPropertyOptional({
description:
"Why the wagon is going to maintenance. Stored on the wagon's status-history " +
'log alongside the train it was detached from, matching the fleet desk flow.',
maxLength: 500,
})
@IsOptional()
@IsString()
@MaxLength(500)
note?: string;
}

View File

@@ -23,6 +23,7 @@ import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
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 { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
import { UpdateTrainYardDto } from './dto/update-train-yard.dto';
@@ -135,8 +136,14 @@ export class TrainBuilderController {
@Param('id', ParseUUIDPipe) id: string,
@Param('wagonId', ParseUUIDPipe) wagonId: string,
@CurrentUser() user: AuthUserPayload,
@Body() dto?: SendWagonToMaintenanceDto,
) {
return this.trainBuilderService.sendWagonToMaintenance(id, wagonId, resolveAuthUserId(user));
return this.trainBuilderService.sendWagonToMaintenance(
id,
wagonId,
resolveAuthUserId(user),
dto?.note,
);
}
@Post(':id/reorder-wagons')

View File

@@ -0,0 +1,83 @@
import { buildMaintenanceNotes, formatTrainRunLabel } from './train-builder.service';
describe('formatTrainRunLabel', () => {
it('names the train by its export and import run numbers', () => {
// What staff actually recognise — NOT the internal code (TRN-LEDGER-PW2).
expect(
formatTrainRunLabel({
exportTrainNumber: '9201',
importTrainNumber: '9202',
trainNumber: 'TRN-7',
code: 'TRN-LEDGER-PW2',
}),
).toBe('export 9201 / import 9202');
});
it('shows only the run number that is set', () => {
expect(
formatTrainRunLabel({ exportTrainNumber: '9201', code: 'TRN-LEDGER-PW2' }),
).toBe('export 9201');
expect(
formatTrainRunLabel({ importTrainNumber: '9202', code: 'TRN-LEDGER-PW2' }),
).toBe('import 9202');
});
it('falls back to the train number, then the code, when no run is set', () => {
expect(formatTrainRunLabel({ trainNumber: 'TRN-7', code: 'TRN-LEDGER-PW2' })).toBe(
'TRN-7',
);
expect(formatTrainRunLabel({ code: 'TRN-LEDGER-PW2' })).toBe('TRN-LEDGER-PW2');
});
it('ignores blank run numbers rather than printing empty labels', () => {
expect(
formatTrainRunLabel({ exportTrainNumber: ' ', importTrainNumber: null, code: 'C-1' }),
).toBe('C-1');
});
it('never returns an empty label', () => {
expect(formatTrainRunLabel({})).toBe('unknown');
});
});
describe('buildMaintenanceNotes', () => {
it('records the operator reason together with the train it came off', () => {
const notes = buildMaintenanceNotes('export 9201 / import 9202', 'Brake shoe worn through');
expect(notes.statusLogNote).toBe(
'Brake shoe worn through (detached from train export 9201 / import 9202)',
);
expect(notes.movementNote).toBe(
'Sent to maintenance from train export 9201 / import 9202: Brake shoe worn through',
);
});
it('still records the train number when no reason is given', () => {
// The reason is optional, but which train a wagon left is never optional —
// the history has to answer that on its own.
const notes = buildMaintenanceNotes('export 9201 / import 9202');
expect(notes.statusLogNote).toBe('Detached from train export 9201 / import 9202');
expect(notes.movementNote).toBe('Sent to maintenance from train export 9201 / import 9202');
});
it('treats a whitespace-only reason as no reason', () => {
const notes = buildMaintenanceNotes('export 9201 / import 9202', ' ');
expect(notes.statusLogNote).toBe('Detached from train export 9201 / import 9202');
expect(notes.movementNote).toBe('Sent to maintenance from train export 9201 / import 9202');
});
it('trims padding around a real reason', () => {
const notes = buildMaintenanceNotes('export 9201 / import 9202', ' Coupler damage ');
expect(notes.statusLogNote).toBe('Coupler damage (detached from train export 9201 / import 9202)');
expect(notes.movementNote).toBe('Sent to maintenance from train export 9201 / import 9202: Coupler damage');
});
it('handles a null reason from an older client', () => {
expect(buildMaintenanceNotes('export 9201 / import 9202', null).statusLogNote).toBe(
'Detached from train export 9201 / import 9202',
);
});
});

View File

@@ -19,6 +19,7 @@ import { TrainSet } from '../train-sets/entities/train-set.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
import { WagonStatusLog } from '../wagons/entities/wagon-status-log.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
import { BuildTrainDto } from './dto/build-train.dto';
@@ -530,7 +531,12 @@ export class TrainBuilderService {
* moves to MAINTENANCE status (not AVAILABLE), so it is not re-coupled until
* it clears maintenance. The freed sequence gap is closed.
*/
async sendWagonToMaintenance(id: string, wagonId: string, userId?: string | null) {
async sendWagonToMaintenance(
id: string,
wagonId: string,
userId?: string | null,
note?: string | null,
) {
await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
@@ -542,6 +548,8 @@ export class TrainBuilderService {
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
);
}
const previousStatus = wagon.status;
const notes = buildMaintenanceNotes(formatTrainRunLabel(train), note);
await manager.getRepository(Wagon).update(wagon.id, {
trainId: null,
sequenceNumber: null,
@@ -549,6 +557,22 @@ export class TrainBuilderService {
importTrainNumber: null,
exportTrainNumber: null,
});
// Status-history row, same as the fleet desk's "Send to maintenance" —
// without it a maintenance detach made here is invisible in the wagon's
// status history. The train number is folded into the note so the history
// answers "which train did it come off, and why" in one line.
if (previousStatus !== WagonStatus.Maintenance) {
await manager.getRepository(WagonStatusLog).save(
manager.getRepository(WagonStatusLog).create({
wagonId: wagon.id,
fromStatus: previousStatus,
toStatus: WagonStatus.Maintenance,
changedByUserId: userId ?? null,
note: notes.statusLogNote,
}),
);
}
// Audit row: which train it came off and when. The wagon does not change
// yard here, so from/to are the same — the ledger is the wagon's history
// surface, and a maintenance detach has to be in it.
@@ -560,7 +584,7 @@ export class TrainBuilderService {
fromYardId: yardId,
toYardId: yardId,
kind: WagonMovementKind.Maintenance,
note: `Sent to maintenance from train ${train.trainNumber ?? train.code}`,
note: notes.movementNote,
occurredAt: new Date(),
}),
);
@@ -1111,6 +1135,48 @@ export class TrainBuilderService {
* pinned to a reordered wagon adopt the wagon's new position; unpinned slots
* trail behind in their previous relative order.
*/
/**
* How a train is named in a wagon's history. Staff identify a train by its
* OPERATIONAL run numbers — the fixed export (odd) and import (even) numbers
* typed at build time — not by its internal code (`TRN-LEDGER-PW2`), which is a
* ledger key and means nothing on the ground. Both runs are shown when set,
* since one built train carries the pair. Falls back to the train number, then
* the code, only when no run number exists.
*/
export function formatTrainRunLabel(train: {
exportTrainNumber?: string | null;
importTrainNumber?: string | null;
trainNumber?: string | null;
code?: string | null;
}): string {
const exportNo = train.exportTrainNumber?.trim();
const importNo = train.importTrainNumber?.trim();
const runs = [
exportNo ? `export ${exportNo}` : null,
importNo ? `import ${importNo}` : null,
].filter(Boolean);
if (runs.length) return runs.join(' / ');
return train.trainNumber?.trim() || train.code?.trim() || 'unknown';
}
/**
* Notes for a maintenance detach. The train's run numbers are always recorded —
* staff need to know which consist a wagon came off — and the operator's reason
* is folded in when given, so the wagon's status history answers "which train,
* and why" in one line (matching the fleet desk's Send-to-maintenance note).
*/
export function buildMaintenanceNotes(trainLabel: string, note?: string | null) {
const reason = note?.trim();
return {
statusLogNote: reason
? `${reason} (detached from train ${trainLabel})`
: `Detached from train ${trainLabel}`,
movementNote: reason
? `Sent to maintenance from train ${trainLabel}: ${reason}`
: `Sent to maintenance from train ${trainLabel}`,
};
}
export function orderSlotsByWagonSequence<
T extends Pick<TrainSetWagon, 'sequenceNo' | 'physicalWagonId'>,
>(slots: T[], newSeq: Map<string, number>): T[] {

View File

@@ -1,5 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayMaxSize,
IsArray,
IsInt,
IsNotEmpty,
IsOptional,
@@ -11,11 +13,14 @@ import {
} from 'class-validator';
/**
* A count-only wagon-transfer request. The requester picks source yard, wagon
* type, destination yard and HOW MANY — never the specific wagons; OCC hand-picks
* those at fulfilment. The quantity may not exceed the AVAILABLE wagons of that
* type currently in the source yard (enforced in the service, which is the only
* layer that can count them), and a reason is mandatory.
* A wagon-transfer request. The requester picks source yard, wagon type,
* destination yard and HOW MANY. The quantity may not exceed the AVAILABLE
* wagons of that type currently in the source yard (enforced in the service,
* which is the only layer that can count them), and a reason is mandatory.
*
* The requester may additionally name the specific wagons they want via
* `preferredWagonIds`. That is a preference recorded for OCC, not a
* reservation — the count still drives fulfilment.
*/
export class CreateTransferRequestDto {
@IsUUID()
@@ -38,6 +43,17 @@ export class CreateTransferRequestDto {
@MaxLength(2000)
reason!: string;
@ApiPropertyOptional({
description:
'Specific wagons the requester wants, if they picked any. A preference for OCC — the wagons are not reserved.',
type: [String],
})
@IsOptional()
@IsArray()
@ArrayMaxSize(1000)
@IsUUID('4', { each: true })
preferredWagonIds?: string[];
@ApiPropertyOptional({ description: 'Optional note for the fulfilling staff' })
@IsOptional()
@IsString()

View File

@@ -56,6 +56,14 @@ export class WagonTransferRequest extends BaseEntity {
})
status!: WagonTransferRequestStatus;
/**
* The wagons the requester specifically asked for, when they picked any. A
* preference, not a reservation — the wagons stay AVAILABLE to everyone else,
* and OCC may still send different ones. Null/empty on a plain count request.
*/
@Column({ name: 'preferred_wagon_ids', type: 'uuid', array: true, nullable: true })
preferredWagonIds?: string[] | null;
@Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
requestedByUserId?: string | null;

View File

@@ -149,6 +149,19 @@ describe('WagonTransferRequestsService — partial fulfilment', () => {
expect(result.skipped).toHaveLength(0);
});
it('auto-picks the wagons the requester named ahead of the rest', async () => {
// Asked for 2 and named w-3 — the auto-pick must take it even though
// wagon-number order would have sent w-0 and w-1.
build(request({ quantity: 2, preferredWagonIds: ['w-3'] }));
wagonRepo.find.mockResolvedValue(availableWagons(5));
await service.bulkFulfill(['req-1']);
const [{ wagonIds }] = wagonsService.bulkTransfer.mock.calls[0];
expect(wagonIds).toHaveLength(2);
expect(wagonIds[0]).toBe('w-3');
});
it('skips only when the yard has nothing to give', async () => {
wagonRepo.find.mockResolvedValue([]);
@@ -253,6 +266,84 @@ describe('WagonTransferRequestsService — partial fulfilment', () => {
expect(requestRepo.save).not.toHaveBeenCalled();
});
it('records the wagons the requester hand-picked', async () => {
wagonRepo.count.mockResolvedValue(20);
wagonRepo.find.mockResolvedValue(availableWagons(3));
await service.createRequest(
{
fromYardId: 'yard-a',
toYardId: 'yard-b',
wagonTypeId: 'type-1',
quantity: 3,
reason: 'Grain campaign',
preferredWagonIds: ['w-0', 'w-1', 'w-2'],
},
'user-1',
);
expect(stored.preferredWagonIds).toEqual(['w-0', 'w-1', 'w-2']);
});
it('leaves the picks null on a plain count request', async () => {
wagonRepo.count.mockResolvedValue(20);
await service.createRequest(
{
fromYardId: 'yard-a',
toYardId: 'yard-b',
wagonTypeId: 'type-1',
quantity: 5,
reason: 'Grain campaign',
},
'user-1',
);
expect(stored.preferredWagonIds).toBeNull();
});
it('refuses picks that are not available in the source yard', async () => {
wagonRepo.count.mockResolvedValue(20);
// Sitting in another yard — the requester's list is stale.
wagonRepo.find.mockResolvedValue([
{ ...availableWagons(1)[0], currentYardId: 'yard-z' },
]);
await expect(
service.createRequest(
{
fromYardId: 'yard-a',
toYardId: 'yard-b',
wagonTypeId: 'type-1',
quantity: 1,
reason: 'Grain campaign',
preferredWagonIds: ['w-0'],
},
'user-1',
),
).rejects.toThrow(/no longer available in the source yard/i);
expect(requestRepo.save).not.toHaveBeenCalled();
});
it('refuses more picks than the requested quantity', async () => {
wagonRepo.count.mockResolvedValue(20);
await expect(
service.createRequest(
{
fromYardId: 'yard-a',
toYardId: 'yard-b',
wagonTypeId: 'type-1',
quantity: 2,
reason: 'Grain campaign',
preferredWagonIds: ['w-0', 'w-1', 'w-2'],
},
'user-1',
),
).rejects.toThrow(/picked 3 wagon\(s\) but are requesting 2/i);
expect(requestRepo.save).not.toHaveBeenCalled();
});
it('still refuses a same-yard move', async () => {
await expect(
service.createRequest(

View File

@@ -27,6 +27,15 @@ import { WagonMovement } from './entities/wagon-movement.entity';
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
import { WagonsService } from './wagons.service';
/**
* A request as sent to clients: the entity plus the resolved wagon numbers for
* whatever the requester hand-picked, so the desk can name them without a
* second round trip.
*/
export interface TransferRequestView extends WagonTransferRequest {
preferredWagons?: Array<{ id: string; wagonNumber: string }>;
}
/** Bundled per-user activity: requests they touched + wagons they moved. */
export interface TransferHistory {
requests: WagonTransferRequest[];
@@ -75,11 +84,15 @@ export class WagonTransferRequestsService {
) {}
/**
* Record a PENDING request. Count-only — no wagons are picked here, but the
* count IS capped by what the source yard can hand over right now: a request
* may not exceed the AVAILABLE, uncoupled wagons of that type in the source
* yard (the same number the yard desk shows). A reason is mandatory and is
* shown on the OCC queue.
* Record a PENDING request. The count is capped by what the source yard can
* hand over right now: a request may not exceed the AVAILABLE, uncoupled
* wagons of that type in the source yard (the same number the yard desk
* shows). A reason is mandatory and is shown on the OCC queue.
*
* The requester may also name the wagons they want (`preferredWagonIds`).
* Those are validated against the source yard here so a bad pick is rejected
* at request time rather than surfacing at fulfilment, but they are only a
* preference — the wagons are not reserved and OCC may send others.
*/
async createRequest(
dto: CreateTransferRequestDto,
@@ -104,11 +117,15 @@ export class WagonTransferRequestsService {
`Only ${available} wagon(s) of this type are available in the source yard — cannot request ${dto.quantity}`,
);
}
const preferredWagonIds = await this.validatePreferredWagons(dto);
const request = this.requestRepo.create({
fromYardId: dto.fromYardId,
toYardId: dto.toYardId,
wagonTypeId: dto.wagonTypeId,
quantity: dto.quantity,
preferredWagonIds,
status: WagonTransferRequestStatus.Pending,
requestedByUserId: userId ?? null,
reason: dto.reason,
@@ -118,6 +135,45 @@ export class WagonTransferRequestsService {
return this.findById(saved.id);
}
/**
* Check the requester's hand-picked wagons against the source yard: each must
* exist, sit in that yard, match the requested type, be AVAILABLE and be
* uncoupled — the same conditions fulfilment will apply. Returns the
* de-duplicated ids, or null when the requester picked nothing.
*/
private async validatePreferredWagons(
dto: CreateTransferRequestDto,
): Promise<string[] | null> {
const ids = [...new Set(dto.preferredWagonIds ?? [])];
if (ids.length === 0) return null;
if (ids.length > dto.quantity) {
throw new BadRequestException(
`You picked ${ids.length} wagon(s) but are requesting ${dto.quantity} — pick at most ${dto.quantity}`,
);
}
const wagons = await this.wagonRepo.find({ where: { id: In(ids) } });
if (wagons.length !== ids.length) {
throw new NotFoundException('One or more selected wagons not found');
}
const unusable = wagons.filter(
(w) =>
w.currentYardId !== dto.fromYardId ||
w.wagonTypeId !== dto.wagonTypeId ||
w.status !== WagonStatus.Available ||
w.trainId != null,
);
if (unusable.length) {
throw new BadRequestException(
`These wagons are no longer available in the source yard: ${unusable
.map((w) => w.wagonNumber)
.join(', ')}`,
);
}
return ids;
}
/**
* AVAILABLE wagons of `wagonTypeId` currently in `yardId` — what OCC can move
* right now. Shown on the desk beside the outstanding count so staff see at a
@@ -179,16 +235,51 @@ export class WagonTransferRequestsService {
: 'r.createdAt';
qb.orderBy(sortColumn, query.sortOrder ?? 'DESC');
return paginateQuery(qb, { page: query.page, pageSize: query.pageSize });
const page = await paginateQuery<WagonTransferRequest>(qb, {
page: query.page,
pageSize: query.pageSize,
});
return { ...page, items: await this.withPreferredWagons(page.items) };
}
async findById(id: string): Promise<WagonTransferRequest> {
/**
* Resolve `preferredWagonIds` into wagon numbers for a page of requests in a
* single query, so the desk can show WHICH wagons were asked for. Ids that no
* longer resolve (purged wagon) simply drop out — the column carries no FK.
*/
private async withPreferredWagons(
requests: WagonTransferRequest[],
): Promise<TransferRequestView[]> {
const ids = [
...new Set(requests.flatMap((r) => r.preferredWagonIds ?? [])),
];
if (ids.length === 0) return requests;
const wagons = await this.wagonRepo.find({
where: { id: In(ids) },
select: { id: true, wagonNumber: true },
});
const byId = new Map(wagons.map((w) => [w.id, w.wagonNumber]));
return requests.map((r) => {
const picked = r.preferredWagonIds ?? [];
if (picked.length === 0) return r;
return Object.assign(r, {
preferredWagons: picked
.filter((id) => byId.has(id))
.map((id) => ({ id, wagonNumber: byId.get(id)! })),
});
});
}
async findById(id: string): Promise<TransferRequestView> {
const request = await this.requestRepo.findOne({
where: { id },
relations: REQUEST_RELATIONS,
});
if (!request) throw new NotFoundException(`Transfer request ${id} not found`);
return request;
const [view] = await this.withPreferredWagons([request]);
return view;
}
/** Wagons still owed on an open request. */
@@ -411,7 +502,7 @@ export class WagonTransferRequestsService {
continue;
}
const remaining = this.remainingOn(request);
const wagons = await this.wagonRepo.find({
const candidates = await this.wagonRepo.find({
where: {
currentYardId: request.fromYardId,
wagonTypeId: request.wagonTypeId,
@@ -419,8 +510,19 @@ export class WagonTransferRequestsService {
trainId: IsNull(),
},
order: { wagonNumber: 'ASC' },
take: remaining,
});
// Honour the requester's picks first — any that are still available in
// the yard go out ahead of the plain wagon-number order, and the rest of
// the instalment is topped up from whatever else is on hand.
const preferred = new Set(request.preferredWagonIds ?? []);
const wagons = (
preferred.size
? [
...candidates.filter((w) => preferred.has(w.id)),
...candidates.filter((w) => !preferred.has(w.id)),
]
: candidates
).slice(0, remaining);
if (wagons.length === 0) {
skipped.push({
id,
@@ -479,7 +581,7 @@ export class WagonTransferRequestsService {
});
return {
requests,
requests: await this.withPreferredWagons(requests),
movements,
meta: {
page: page ?? 1,

View File

@@ -191,6 +191,24 @@ const POSITION_TYPE_PERMISSIONS = [
},
] as const;
/**
* Audit trail access.
*
* Read-only by design: `audit_logs` rows are written solely by
* `AuditInterceptor` and the module exposes no create/update/delete route, so
* there is deliberately no `:create` / `:update` / `:delete` twin to grant.
* The matching `:read` key is derived automatically by
* `deriveReadPermissions` below.
*/
const AUDIT_LOG_PERMISSIONS = [
{
id: "52b2cdef-5313-4954-8c69-0f8c58ea2c1e",
key: "edr_freight_app:audit_log:view",
name: { am: "የኦዲት መዝገብ ይመልከቱ", en: "View audit logs" },
applicationKey: EDR_FREIGHT_APPLICATION.key,
},
] as const;
const EDR_FREIGHT_VIEWABLE_PERMISSIONS = [
...EMPLOYEE_REGISTRATION_PERMISSIONS,
...ROLE_ASSIGNMENT_PERMISSIONS,
@@ -198,6 +216,7 @@ const EDR_FREIGHT_VIEWABLE_PERMISSIONS = [
...HIERARCHY_POSITION_PERMISSIONS,
...HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS,
...POSITION_TYPE_PERMISSIONS,
...AUDIT_LOG_PERMISSIONS,
...BOOKING_RULE_ENGINE_PERMISSIONS,
];

View File

@@ -1622,11 +1622,24 @@ export const FREIGHT_PERMS = {
dispatch: "edr_freight_app:train_scheduling:dispatch",
markPaid: "edr_freight_app:train_scheduling:mark_paid",
expireBooking: "edr_freight_app:train_scheduling:expire_booking",
/**
* Edit a schedule's operational run numbers (train + voyage) before
* dispatch. Separate from `update`: these numbers are what yards and
* customs quote, so changing them is narrower than general scheduling edits.
*/
editTrainNumber: "edr_freight_app:train_scheduling:edit_train_number",
},
fleet: {
view: "edr_freight_app:fleet:view",
manage: "edr_freight_app:fleet:manage",
},
/**
* Audit trail. View-only: the module has no write routes, so this is the
* only key it needs — see AUDIT_LOG_PERMISSIONS in edr-freight.seed.ts.
*/
auditLog: {
view: "edr_freight_app:audit_log:view",
},
admin: "edr_freight_app:admin",
ruleEngine: {
view: (slug: RuleEngineResourceSlug) =>
@@ -2124,6 +2137,7 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.trainScheduling.dispatch,
FREIGHT_PERMS.trainScheduling.markPaid,
FREIGHT_PERMS.trainScheduling.expireBooking,
FREIGHT_PERMS.trainScheduling.editTrainNumber,
FREIGHT_PERMS.fleet.view,
FREIGHT_PERMS.fleet.manage,
...FLEET_GRANULAR_KEYS,
@@ -2288,6 +2302,7 @@ export const POSITION_PERMISSION_PRESETS = {
FREIGHT_PERMS.trainScheduling.cancel,
FREIGHT_PERMS.trainScheduling.reschedule,
FREIGHT_PERMS.trainScheduling.rulesManage,
FREIGHT_PERMS.trainScheduling.editTrainNumber,
FREIGHT_PERMS.fleet.view,
FREIGHT_PERMS.fleet.manage,
...FLEET_GRANULAR_KEYS,

View File

@@ -1,5 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import {
Application,
Organization,
Permission,
Position,
@@ -8,7 +9,23 @@ import {
} from '@tria-plc/iamapi-common';
import { DataSource, EntityManager, In } from 'typeorm';
import { EDR_FREIGHT_POSITIONS } from './edr-freight.seed';
import { EDR_FREIGHT_APPLICATION, EDR_FREIGHT_POSITIONS } from './edr-freight.seed';
/**
* Turn a permission key into a readable fallback name for a row this seeder has
* to mint itself: `edr_freight_app:train_scheduling:edit_train_number` becomes
* "Train scheduling: edit train number". Only used for keys absent from the
* catalog — a key that IS catalogued keeps its curated Amharic/English name.
*/
const nameForKey = (key: string): { en: string } => {
const [, ...rest] = key.split(':');
const [resource, ...action] = rest;
const humanize = (s: string) => s.replace(/_/g, ' ');
const label = action.length
? `${humanize(resource)}: ${humanize(action.join(' '))}`
: humanize(resource);
return { en: label.charAt(0).toUpperCase() + label.slice(1) };
};
const SEED_FLAG = 'SEED_EDR_ORG';
const EDR_ORG_KEY = 'edr_freight';
@@ -83,7 +100,19 @@ export class FreightPositionsSeeder {
);
}
/** Resolve every permission key referenced by any position to its id. */
/**
* Resolve every permission key referenced by any position to its id, minting
* the rows that do not exist yet.
*
* The position presets draw from FREIGHT_PERMS (the registry), which is
* broader than the EDR_FREIGHT_PERMISSIONS catalog EdrOrgSeeder inserts —
* module keys like `train_scheduling:*` live only in the registry. So every
* newly-added preset key would otherwise abort boot with
* `missing_permissions:<key>` until someone hand-inserted it. Ensuring them
* here keeps this seeder self-sufficient: it declares the keys it needs, so
* it is the one that guarantees they exist. Same approach, and the same
* id-less insert reasoning, as FreightNotificationPermissionsSeeder.
*/
private async loadPermissionIds(
manager: EntityManager,
): Promise<Map<string, string>> {
@@ -91,16 +120,54 @@ export class FreightPositionsSeeder {
...new Set(EDR_FREIGHT_POSITIONS.flatMap((p) => p.permissionKeys)),
];
const permissions = await manager.getRepository(Permission).find({
const read = async () => {
const rows = await manager.getRepository(Permission).find({
where: { key: In(keys) },
select: { id: true, key: true },
});
return new Map(rows.map((p) => [p.key, p.id as string]));
};
const map = new Map(permissions.map((p) => [p.key, p.id as string]));
let map = await read();
const missing = keys.filter((key) => !map.has(key));
if (missing.length > 0) {
throw new Error(`missing_permissions:${missing.join(',')}`);
if (missing.length === 0) {
return map;
}
const application = await manager.getRepository(Application).findOne({
where: { key: EDR_FREIGHT_APPLICATION.key },
select: { id: true },
});
if (!application?.id) {
throw new Error(`missing_application:${EDR_FREIGHT_APPLICATION.key}`);
}
// Ids are left to the column default and never sent: iam.permissions has
// two unique columns (PK id, UQ key) and ON CONFLICT can only target one,
// so a hand-minted id already owned by a retired key would slip past
// ON CONFLICT (key) and die on the PK.
await manager
.createQueryBuilder()
.insert()
.into(Permission)
.values(
missing.map((key) => ({
key,
name: nameForKey(key),
applicationId: application.id as string,
})),
)
.orIgnore()
.execute();
this.logger.log(
`Seeded ${missing.length} permission(s) referenced by positions but absent from the catalog: ${missing.join(', ')}`,
);
map = await read();
const stillMissing = keys.filter((key) => !map.has(key));
if (stillMissing.length > 0) {
throw new Error(`missing_permissions:${stillMissing.join(',')}`);
}
return map;

View File

@@ -41,6 +41,7 @@ import MyProfilePage from "./pages/dashboard/MyProfilePage";
import OverviewPage from "./pages/dashboard/OverviewPage";
import ReportsHubPage from "./pages/reports/ReportsHubPage";
import ReportPage from "./pages/reports/ReportPage";
import AuditLogsPage from "./pages/AuditLogsPage";
import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage";
import PaymentsPage from "./pages/payments/PaymentsPage";
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
@@ -188,6 +189,7 @@ const App = () => {
<Route path="overview" element={<RequirePermission permission={FREIGHT_PERMS.overview.view}><OverviewPage /></RequirePermission>} />
<Route path="reports" element={<RequirePermission permission={FREIGHT_PERMS.reports.view}><ReportsHubPage /></RequirePermission>} />
<Route path="reports/:reportKey" element={<RequirePermission permission={FREIGHT_PERMS.reports.view}><ReportPage /></RequirePermission>} />
<Route path="audit-logs" element={<RequirePermission permission={FREIGHT_PERMS.auditLog.view}><AuditLogsPage /></RequirePermission>} />
{/* Dev/testing page for the mock AI booking assistant. */}
<Route
path="ai-booking-mock-test"

View File

@@ -142,6 +142,21 @@ export function BookingSchedulingWindowCard({
schedule?.reference ??
(schedule ? "Assigned train" : null);
// Before the batch engine allocates, the only train on the booking is the one
// the customer picked at day-commit — staff review that during Operation
// Review, so it is labelled as a request, not as a confirmed allocation.
const isRequested = schedule?.isRequested === true;
const trainRowLabel = isRequested ? "Requested train" : "Scheduled on train";
// A train that has already left (or is past its planned departure) can no
// longer carry this booking, so accepting onto it would be wrong. Called out
// here because this card sits above the staff-actions toolbar.
const departureIso =
schedule?.actualDepartureAt ?? schedule?.scheduledDepartureDate ?? null;
const hasDeparted = schedule?.actualDepartureAt
? true
: departureIso !== null && new Date(departureIso).getTime() <= nowMs;
return (
<SectionCard
icon={CalendarClock}
@@ -153,7 +168,7 @@ export function BookingSchedulingWindowCard({
<Stack gap="sm">
{trainLabel ? (
<Row
label="Scheduled on train"
label={trainRowLabel}
value={trainLabel}
hint={
schedule?.reference && schedule.reference !== trainLabel
@@ -163,13 +178,21 @@ export function BookingSchedulingWindowCard({
/>
) : (
<Row
label="Scheduled on train"
label={trainRowLabel}
value="Not yet allocated"
tone="muted"
hint="The booking has not been placed on a train schedule"
/>
)}
{isRequested && trainLabel ? (
<Text size="xs" c="dimmed">
{hasDeparted
? "This train has already departed — accepting the operation will not place the booking on it."
: "Picked by the customer at day-commit. Accepting the operation releases the booking to the batch pool for this train."}
</Text>
) : null}
{schedule?.status ? (
<Group justify="space-between" wrap="nowrap">
<Text size="sm" c="dimmed">
@@ -231,9 +254,14 @@ export function BookingSchedulingWindowCard({
formatStamp(schedule.scheduledDepartureDate) ??
"—"
}
tone={isRequested && hasDeparted ? "danger" : undefined}
hint={
schedule.actualDepartureAt
? `Actual · planned ${formatStamp(schedule.scheduledDepartureDate) ?? "—"}`
: departureIso && !hasDeparted
? `Planned · departs ${formatRelative(departureIso, nowMs)}`
: hasDeparted
? "Planned — already past"
: "Planned"
}
/>

View File

@@ -1006,7 +1006,7 @@ export function ReleaseOrderCard({
clearance: ClearanceViewLike & { vesselDepartureDate?: string | null };
onChanged?: () => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [files, setFiles] = useState<File[]>([]);
const [vesselDate, setVesselDate] = useState<Date | null>(
clearance.vesselDepartureDate ? new Date(clearance.vesselDepartureDate) : null,
);
@@ -1020,7 +1020,14 @@ export function ReleaseOrderCard({
Release Order
</Text>
<Stack gap="sm">
<FileInput label="Release Order" value={file} onChange={setFile} size="sm" />
<FileInput
label="Release Order"
placeholder="Select one or more files"
multiple
value={files}
onChange={setFiles}
size="sm"
/>
<DateInput
label="Vessel departure date"
value={vesselDate}
@@ -1032,15 +1039,15 @@ export function ReleaseOrderCard({
<Button
color="edr-green"
loading={loading}
disabled={!file || !vesselDate}
disabled={files.length === 0 || !vesselDate}
onClick={async () => {
if (!file || !vesselDate) return;
if (files.length === 0 || !vesselDate) return;
setLoading(true);
try {
const iso = vesselDate.toISOString().slice(0, 10);
const result = isBooking
? await bookingsService.uploadReleaseOrder(entityId, file, iso)
: await contractsService.uploadReleaseOrder(entityId, file, iso);
? await bookingsService.uploadReleaseOrder(entityId, files, iso)
: await contractsService.uploadReleaseOrder(entityId, files, iso);
if (result.hold) {
toast.error(result.holdReason ?? "Vessel date too soon");
} else {

View File

@@ -10,11 +10,10 @@ import {
toIsoDate,
useDoCollectionDates,
} from "@/components/contracts/DoCollectionDateFields";
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
import { PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { contractsService } from "@/services/contracts.service";
import { bookingsService } from "@/services/bookings.service";
import type { Freight } from "@edr/types";
import { isDeliveryOrderFileCode, isReleaseOrderFileCode, type Freight } from "@edr/types";
export type GlClearanceUploadKind = "do" | "ro";
@@ -44,9 +43,8 @@ export function GlClearanceUploadModal({
vesselArrivalDate,
doCollectedDate,
onSuccess,
onPreview,
}: GlClearanceUploadModalProps) {
const [file, setFile] = useState<File | null>(null);
const [files, setFiles] = useState<File[]>([]);
const [vesselDate, setVesselDate] = useState<Date | null>(
vesselDepartureDate ? new Date(vesselDepartureDate) : null,
);
@@ -66,16 +64,16 @@ export function GlClearanceUploadModal({
const isDo = kind === "do";
const isRo = kind === "ro";
const replaceMode = isDo
? Boolean(findWorkflowFile(workflowFiles, "delivery_order"))
: Boolean(findWorkflowFile(workflowFiles, "release_order"));
? workflowFiles.some((f) => isDeliveryOrderFileCode(f.code) && f.file)
: workflowFiles.some((f) => isReleaseOrderFileCode(f.code) && f.file);
const close = () => {
setFile(null);
setFiles([]);
onClose();
};
const submit = async () => {
if (!file || !kind) return;
if (files.length === 0 || !kind) return;
if (isRo && !vesselDate) {
toast.error("Vessel departure date is required.");
return;
@@ -93,23 +91,23 @@ export function GlClearanceUploadModal({
doCollectedDate: toIsoDate(doDates.doCollected)!,
};
if (isBooking) {
await bookingsService.uploadDeliveryOrder(entityId, file, dates);
await bookingsService.uploadDeliveryOrder(entityId, files, dates);
} else {
await contractsService.uploadDeliveryOrder(entityId, file, dates);
await contractsService.uploadDeliveryOrder(entityId, files, dates);
}
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");
} else {
const iso = vesselDate!.toISOString().slice(0, 10);
const result = isBooking
? await bookingsService.uploadReleaseOrder(entityId, file, iso)
: await contractsService.uploadReleaseOrder(entityId, file, iso);
? await bookingsService.uploadReleaseOrder(entityId, files, iso)
: await contractsService.uploadReleaseOrder(entityId, files, iso);
if (result.hold) {
toast.error(result.holdReason ?? "Vessel date too soon");
} else {
toast.success(replaceMode ? "Release Order updated" : "Release Order uploaded");
}
}
setFile(null);
setFiles([]);
onSuccess?.();
close();
} catch (e) {
@@ -152,14 +150,15 @@ export function GlClearanceUploadModal({
<DoCollectionDateFields value={doDates} onChange={setDoDates} />
)}
<PhasedFileDropzone
label={isDo ? "Delivery Order file" : "Release Order file"}
description={isDo ? "Any file type." : "PDF or image."}
<PhasedMultiFileDropzone
label={isDo ? "Delivery Order files" : "Release Order files"}
description={
isDo ? "Any file type. Add as many files as needed." : "PDF or image. Add as many files as needed."
}
accept={isDo ? "*/*" : undefined}
value={file}
onChange={setFile}
value={files}
onChange={setFiles}
replaceMode={replaceMode}
onPreview={onPreview}
/>
<Group justify="flex-end" gap="sm">
@@ -170,7 +169,7 @@ export function GlClearanceUploadModal({
color="edr-green"
loading={loading}
disabled={
!file ||
files.length === 0 ||
(isRo && !vesselDate) ||
(isDo && !doDatesComplete(doDates))
}

View File

@@ -34,12 +34,15 @@ import {
Truck,
Upload,
} from "lucide-react";
import type { Freight } from "@edr/types";
import {
deliveryOrderFileLabel,
isDeliveryOrderFileCode,
type Freight,
} from "@edr/types";
import toast from "react-hot-toast";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { ExportClearanceStepper } from "@/components/contracts/ExportClearanceStepper";
import { PhasedDocumentUploadField } from "@/components/contracts/PhasedDocumentUploadField";
import {
DoCollectionDateFields,
doDatesComplete,
@@ -2133,35 +2136,15 @@ function DeliveryOrderStep({
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}) {
const [files, setFiles] = useState<Record<string, File | null>>({
delivery_order: null,
});
const [files, setFiles] = useState<File[]>([]);
const [doDates, setDoDates] = useDoCollectionDates({
vesselArrivalDate,
doCollectedDate,
});
const [loading, setLoading] = useState(false);
const hasFile = Boolean(files.delivery_order);
return (
<PhasedDocumentUploadField
fields={[{ key: "delivery_order", label: "Delivery Order" }]}
files={files}
onChange={(key, file) => setFiles((prev) => ({ ...prev, [key]: file }))}
workflowFiles={workflowFiles}
replaceMode={replaceMode}
loading={loading}
disabled={!hasFile || !doDatesComplete(doDates)}
helperText="Upload the Djibouti Delivery Order (DO) and record when the vessel arrived and when the DO was collected."
submitLabel={replaceMode ? "Replace DO" : "Upload DO"}
extraFields={
<DoCollectionDateFields value={doDates} onChange={setDoDates} />
}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
onSubmit={async () => {
const file = files.delivery_order;
if (!file || !doDatesComplete(doDates)) return;
const submit = async () => {
if (files.length === 0 || !doDatesComplete(doDates)) return;
const dates = {
vesselArrivalDate: toIsoDate(doDates.vesselArrival)!,
doCollectedDate: toIsoDate(doDates.doCollected)!,
@@ -2169,11 +2152,11 @@ function DeliveryOrderStep({
setLoading(true);
try {
if (isBooking) {
await bookingsService.uploadDeliveryOrder(entityId, file, dates);
await bookingsService.uploadDeliveryOrder(entityId, files, dates);
} else {
await contractsService.uploadDeliveryOrder(entityId, file, dates);
await contractsService.uploadDeliveryOrder(entityId, files, dates);
}
setFiles({ delivery_order: null });
setFiles([]);
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");
onChanged?.();
} catch (e) {
@@ -2181,8 +2164,54 @@ function DeliveryOrderStep({
} finally {
setLoading(false);
}
}}
};
return (
<Stack gap="sm">
<Text size="sm" c="dimmed">
Upload the Djibouti Delivery Order (DO) and record when the vessel arrived and when the
DO was collected. Add as many files as needed.
</Text>
{workflowFiles
.filter((wf) => isDeliveryOrderFileCode(wf.code) && wf.file)
.map((wf, index) => (
<PhasedUploadedFileRow
key={wf.code}
label={deliveryOrderFileLabel(wf.code, index)}
file={wf.file!}
onView={onViewFile}
onDownload={onDownloadFile}
/>
))}
<DoCollectionDateFields value={doDates} onChange={setDoDates} />
<PhasedMultiFileDropzone
label="Delivery Order files"
description={
replaceMode
? "Replace the DO — upload one or more files (any file type)."
: "Upload one or more Delivery Order files (any file type)."
}
accept="*/*"
value={files}
onChange={setFiles}
replaceMode={replaceMode}
disabled={loading}
/>
<Button
color="edr-green"
loading={loading}
disabled={files.length === 0 || !doDatesComplete(doDates)}
leftSection={<Upload size={16} />}
fullWidth
onClick={() => void submit()}
>
{replaceMode ? "Replace DO" : "Upload DO"}
</Button>
</Stack>
);
}

View File

@@ -8,6 +8,7 @@ import {
FileSignature,
FileText,
Hammer,
History,
Landmark,
LayoutDashboard,
LayoutGrid,
@@ -500,6 +501,12 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
FREIGHT_PERMS.settings.supportContent.manage,
],
},
{
label: "Audit logs",
href: "/dashboard/audit-logs",
icon: <History />,
permission: FREIGHT_PERMS.auditLog.view,
},
{
label: "Configuration",
href: "/dashboard/configuration",

View File

@@ -88,7 +88,7 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
const handleBuild = async () => {
if (!trainName.trim()) {
toast({
title: "Enter the vogue number",
title: "Enter the voyage number",
variant: "destructive",
});
return;
@@ -150,8 +150,8 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
wagons are attached on the next screen.
</Text>
<TextInput
label="Vogue number"
placeholder="Enter vogue number"
label="Voyage number"
placeholder="Enter voyage number"
value={trainName}
onChange={(e) => setTrainName(e.currentTarget.value)}
maxLength={100}

View File

@@ -1,7 +1,9 @@
import { useMemo } from "react";
import { Link } from "react-router-dom";
import { ArrowRight, Landmark, Package } from "lucide-react";
import {
Accordion,
Anchor,
Badge,
Button,
Checkbox,
@@ -50,9 +52,20 @@ function EligibleBookingRow({
<Stack gap={4} style={{ flex: 1 }}>
<Group gap="xs" wrap="wrap">
<Package size={14} />
<Text fw={600} size="sm">
{/* Opens the booking in a new tab: the row is a selection control in
an allocation flow, so navigating away would lose staff's picks. */}
<Anchor
component={Link}
to={`/dashboard/booking-requests/${booking.id}`}
target="_blank"
fw={600}
size="sm"
c="edr-green.8"
underline="hover"
onClick={(e) => e.stopPropagation()}
>
{booking.reference}
</Text>
</Anchor>
{resolvedFreightType ? (
<Badge variant="outline" size="xs">
{resolvedFreightType}

View File

@@ -0,0 +1,344 @@
import {
Alert,
Badge,
Box,
Button,
Card,
Group,
Loader,
Modal,
Stack,
Text,
Textarea,
TextInput,
ThemeIcon,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import {
ArrowRight,
Ban,
CircleAlert,
Merge,
Search,
TriangleAlert,
} from "lucide-react";
import { useMemo, useState } from "react";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
function parseError(error: unknown, fallback: string): 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;
}
const fmtDate = (iso: string) =>
new Date(iso).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
});
export interface MergeScheduleTrainModalProps {
scheduleId: string | null;
/** This schedule's current train — excluded from the picker. */
currentTrainId: string | null;
scheduleReference?: string | null;
opened: boolean;
onClose: () => void;
onMerged?: () => void;
}
/**
* Merge another train into this schedule.
*
* This schedule always survives: its train set is repointed at the chosen
* train, that train's wagons join this consist, and the emptied train is
* deactivated. When the chosen train also runs a schedule on the SAME DAY, that
* schedule's bookings move here and it is removed — its other-day schedules
* gain the wagons only. The server computes all of that in `previewMerge`, so
* the summary below is exactly what the commit will perform.
*/
export default function MergeScheduleTrainModal({
scheduleId,
currentTrainId,
scheduleReference,
opened,
onClose,
onMerged,
}: MergeScheduleTrainModalProps) {
const { toast } = useToast();
const [selectedTrainId, setSelectedTrainId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [reason, setReason] = useState("");
const { data: trains = [], isLoading: trainsLoading } = useQuery({
...api.trains.list.queryOptions(),
enabled: opened,
});
// The schedule's own train cannot be merged into itself.
const options = useMemo(() => {
const q = search.trim().toLowerCase();
return trains
.filter((t) => t.id !== currentTrainId)
.filter((t) =>
q
? `${t.code} ${t.trainNumber ?? ""} ${t.trainName ?? ""}`
.toLowerCase()
.includes(q)
: true,
);
}, [trains, currentTrainId, search]);
const { data: preview, isFetching: previewLoading } = useQuery({
...api.trainScheduling.previewScheduleMerge.queryOptions({
input: { id: scheduleId ?? "", targetTrainId: selectedTrainId ?? "" },
}),
enabled: opened && Boolean(scheduleId && selectedTrainId),
});
const merge = useMutation(api.trainScheduling.mergeScheduleTrain.mutationOptions());
const close = () => {
setSelectedTrainId(null);
setSearch("");
setReason("");
onClose();
};
const submit = async () => {
if (!scheduleId || !selectedTrainId || !preview?.canMerge) return;
try {
await merge.mutateAsync({
id: scheduleId,
targetTrainId: selectedTrainId,
...(reason.trim() ? { reason: reason.trim() } : {}),
});
toast({ title: "Trains merged" });
onMerged?.();
close();
} catch (err) {
toast({
title: "Merge failed",
description: parseError(err, "Could not merge the trains"),
variant: "destructive",
});
}
};
return (
<Modal
opened={opened}
onClose={close}
centered
size="lg"
radius="lg"
title={
<Group gap="sm">
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
<Merge size={18} />
</ThemeIcon>
<Box>
<Text fw={600} lh={1.2}>
Merge another train into this one
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
{scheduleReference ?? "This departure survives the merge"}
</Text>
</Box>
</Group>
}
>
<Stack gap="md">
<TextInput
placeholder="Search train code or number…"
leftSection={<Search size={15} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
radius="md"
/>
{trainsLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
) : options.length === 0 ? (
<Text size="sm" c="dimmed" py="sm">
No other trains available to merge.
</Text>
) : (
<Box
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))",
gap: 8,
maxHeight: 190,
overflowY: "auto",
}}
>
{options.map((t) => {
const on = t.id === selectedTrainId;
return (
<Card
key={t.id}
withBorder
radius="md"
padding="xs"
onClick={() => setSelectedTrainId(t.id)}
style={{
cursor: "pointer",
borderColor: on
? "var(--mantine-color-edr-green-5)"
: undefined,
background: on
? "var(--mantine-color-edr-green-0)"
: undefined,
}}
>
<Text size="sm" fw={on ? 700 : 600} truncate>
{t.code}
</Text>
<Text size="xs" c="dimmed" truncate>
{t.trainNumber ? `No. ${t.trainNumber}` : "—"}
</Text>
</Card>
);
})}
</Box>
)}
{selectedTrainId && previewLoading ? (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : null}
{selectedTrainId && preview && !previewLoading ? (
<Stack gap="sm">
{preview.blockers.length ? (
<Alert
variant="light"
color="red"
icon={<CircleAlert size={16} />}
title="This merge is blocked"
>
<Stack gap={4}>
{preview.blockers.map((b) => (
<Text size="sm" key={b}>
{b}
</Text>
))}
</Stack>
</Alert>
) : (
<Alert
variant="light"
color="orange"
icon={<TriangleAlert size={16} />}
>
This cannot be undone. Wagons are appended last reorder them
afterwards in the train builder.
</Alert>
)}
<Card withBorder radius="md" padding="sm">
<Group gap={8} wrap="nowrap" mb={8}>
<Text size="sm" fw={700}>
{preview.wagons.current} wagons
</Text>
<ArrowRight size={15} />
<Text size="sm" fw={700} c="edr-green.8">
{preview.wagons.merged} wagons
</Text>
<Badge variant="light" color="edr-green" radius="sm">
+{preview.wagons.incoming} from {preview.targetTrain.code}
</Badge>
</Group>
{preview.absorbedSchedule ? (
<Group gap={6} wrap="nowrap" mb={6}>
<Badge size="sm" color="grape" variant="light" radius="sm">
{fmtDate(preview.absorbedSchedule.scheduledDepartureDate)}
</Badge>
<Text size="sm">
{preview.absorbedSchedule.reference ?? "Same-day schedule"} {" "}
<Text span fw={700}>
{preview.absorbedSchedule.bookingsMoving} booking(s)
</Text>{" "}
move here, then it is removed
</Text>
</Group>
) : null}
{preview.affectedSchedules.map((s) => (
<Group gap={6} wrap="nowrap" mb={4} key={s.id}>
<Badge size="sm" color="blue" variant="light" radius="sm">
{fmtDate(s.scheduledDepartureDate)}
</Badge>
<Text size="sm" c="dimmed">
{s.reference ?? s.id.slice(0, 8)} gains the wagons, keeps
its own bookings
</Text>
</Group>
))}
{preview.untouchedSchedules.map((s) => (
<Group gap={6} wrap="nowrap" mb={4} key={s.id}>
<Badge size="sm" color="gray" variant="light" radius="sm">
{fmtDate(s.scheduledDepartureDate)}
</Badge>
<Text size="sm" c="dimmed">
{s.reference ?? s.id.slice(0, 8)} {s.status.toLowerCase()},
not affected
</Text>
</Group>
))}
{preview.sourceTrainWillDeactivate ? (
<Group gap={6} mt={6} wrap="nowrap">
<Ban size={14} color="var(--mantine-color-red-6)" />
<Text size="sm" c="red.7">
This schedule&apos;s current train is emptied and deactivated.
</Text>
</Group>
) : null}
</Card>
{preview.canMerge ? (
<Textarea
label="Reason"
placeholder="Why the trains are being merged (kept on the audit trail)"
maxLength={500}
autosize
minRows={2}
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
/>
) : null}
</Stack>
) : null}
<Group justify="flex-end" mt="xs">
<Button variant="default" onClick={close}>
Cancel
</Button>
<Button
color="edr-green"
leftSection={<Merge size={16} />}
loading={merge.isPending}
disabled={!preview?.canMerge || previewLoading}
onClick={() => void submit()}
>
Merge trains
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -1,4 +1,5 @@
import {
Anchor,
Badge,
Button,
Group,
@@ -7,7 +8,16 @@ import {
Tabs,
Text,
} from "@mantine/core";
import { ArrowRight, FileText, Landmark, MapPin, Package, Train } from "lucide-react";
import { Link } from "react-router-dom";
import {
ArrowRight,
Building2,
FileText,
Landmark,
MapPin,
Package,
Train,
} from "lucide-react";
import type { EligibleContainerBooking, FreightType } from "@/types/trainScheduling";
@@ -16,6 +26,8 @@ import { EligibleBookingsPanel } from "./EligibleBookingsPanel";
export type AssignedBookingRow = {
id: string;
reference: string;
/** Who booked it — staff scan this list by customer, not by reference. */
customer?: string | null;
weightTons?: number;
isGovernment?: boolean;
wagonsRequired?: number | null;
@@ -90,9 +102,19 @@ export function ScheduleBookingsStep({
>
<Stack gap={4}>
<Group gap="xs">
<Text fw={600} size="sm">
{/* Only the reference is the link — the row also carries a
Remove button, so an interactive control must not nest
inside the anchor. */}
<Anchor
component={Link}
to={`/dashboard/booking-requests/${booking.id}`}
fw={600}
size="sm"
c="edr-green.8"
underline="hover"
>
{booking.reference}
</Text>
</Anchor>
{booking.weightTons != null ? (
<Badge variant="outline" size="xs" color="edr-green">
{booking.weightTons}T
@@ -119,6 +141,14 @@ export function ScheduleBookingsStep({
</Badge>
) : null}
</Group>
{booking.customer ? (
<Group gap={4}>
<Building2 size={12} />
<Text size="xs" c="dimmed">
{booking.customer}
</Text>
</Group>
) : null}
{booking.contractReference || booking.origin || booking.destination ? (
<Group gap="xs">
{booking.contractReference ? (

View File

@@ -0,0 +1,209 @@
import {
Badge,
Box,
Button,
Checkbox,
Group,
ScrollArea,
Stack,
Text,
TextInput,
ThemeIcon,
UnstyledButton,
} from "@mantine/core";
import { Check, Search, Train, X } from "lucide-react";
import { useMemo, useState } from "react";
import type { Wagon } from "@/services/wagon.service";
export interface WagonPickerProps {
/** The wagons on offer — already filtered to what the yard can hand over. */
wagons: Wagon[];
selected: Set<string>;
onChange: (next: Set<string>) => void;
/** Hard ceiling on the selection; further picks are refused once reached. */
max?: number;
emptyMessage?: string;
maxHeight?: number;
}
/**
* Searchable multi-select over a list of wagons, used wherever staff name the
* physical wagons rather than a count. Search matches the wagon number as typed
* (case-insensitively, ignoring the dashes and spaces operators leave out) so
* "1042" finds "GON-1042".
*/
export function WagonPicker({
wagons,
selected,
onChange,
max,
emptyMessage = "No wagons available here right now.",
maxHeight = 260,
}: WagonPickerProps) {
const [query, setQuery] = useState("");
const normalise = (s: string) => s.toLowerCase().replace(/[\s-]/g, "");
const shown = useMemo(() => {
const q = normalise(query.trim());
if (!q) return wagons;
return wagons.filter((w) => normalise(w.wagonNumber).includes(q));
}, [wagons, query]);
const ceiling = max ?? Number.MAX_SAFE_INTEGER;
const full = selected.size >= ceiling;
const toggle = (id: string) => {
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else if (next.size >= ceiling) return; // at the cap — ignore the click
else next.add(id);
onChange(next);
};
/** Select as many of the currently-visible wagons as the cap allows. */
const selectShown = () => {
const next = new Set(selected);
for (const w of shown) {
if (next.size >= ceiling) break;
next.add(w.id);
}
onChange(next);
};
return (
<Stack gap="xs">
<Group gap="xs" wrap="nowrap">
<TextInput
style={{ flex: 1 }}
placeholder="Search wagon number…"
leftSection={<Search size={15} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
rightSection={
query ? (
<UnstyledButton onClick={() => setQuery("")} aria-label="Clear search">
<X size={14} />
</UnstyledButton>
) : null
}
radius="md"
size="sm"
/>
<Button
size="compact-sm"
variant="light"
radius="md"
onClick={selectShown}
disabled={shown.length === 0 || full}
>
Select {query ? "matches" : "all"}
</Button>
{selected.size > 0 ? (
<Button
size="compact-sm"
variant="subtle"
color="gray"
radius="md"
onClick={() => onChange(new Set())}
>
Clear
</Button>
) : null}
</Group>
<Group gap="xs" justify="space-between">
<Text size="xs" c="dimmed">
{shown.length} of {wagons.length} shown
</Text>
<Badge
variant="light"
color={full ? "orange" : selected.size > 0 ? "teal" : "gray"}
radius="sm"
>
{selected.size} selected{max != null ? ` / ${max}` : ""}
</Badge>
</Group>
{wagons.length === 0 ? (
<Text size="sm" c="dimmed" py="sm">
{emptyMessage}
</Text>
) : shown.length === 0 ? (
<Text size="sm" c="dimmed" py="sm">
No wagon matches {query}.
</Text>
) : (
<ScrollArea.Autosize mah={maxHeight} type="auto">
<Box
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))",
gap: 8,
paddingRight: 4,
}}
>
{shown.map((w) => {
const on = selected.has(w.id);
// At the cap, unpicked wagons stop responding — grey them out so
// the dead click is explained before it happens.
const blocked = !on && full;
return (
<UnstyledButton
key={w.id}
onClick={() => toggle(w.id)}
disabled={blocked}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 10px",
borderRadius: 8,
border: `1px solid var(--mantine-color-${
on ? "edr-green-5" : "gray-3"
})`,
background: on
? "var(--mantine-color-edr-green-0)"
: "var(--mantine-color-body)",
opacity: blocked ? 0.45 : 1,
cursor: blocked ? "not-allowed" : "pointer",
transition: "border-color 120ms, background 120ms",
}}
>
<Checkbox
checked={on}
onChange={() => toggle(w.id)}
disabled={blocked}
size="xs"
color="edr-green"
tabIndex={-1}
styles={{ input: { cursor: blocked ? "not-allowed" : "pointer" } }}
/>
<ThemeIcon
variant="light"
color={on ? "edr-green" : "gray"}
size="sm"
radius="sm"
>
{on ? <Check size={12} /> : <Train size={12} />}
</ThemeIcon>
<Text
size="sm"
fw={on ? 700 : 500}
style={{ fontVariantNumeric: "tabular-nums" }}
truncate
>
{w.wagonNumber}
</Text>
</UnstyledButton>
);
})}
</Box>
</ScrollArea.Autosize>
)}
</Stack>
);
}
export default WagonPicker;

View File

@@ -26,6 +26,8 @@ import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type { Wagon } from "@/services/wagon.service";
import WagonPicker from "./WagonPicker";
const stripHtml = (html: string) => html.replace(/<[^>]*>/g, "").trim();
export interface WagonYardWorkspaceModalProps {
@@ -140,6 +142,8 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
const [transferYardId, setTransferYardId] = useState<string | null>(null);
const [transferQty, setTransferQty] = useState(0);
const [transferReason, setTransferReason] = useState("");
/** Specific wagons the requester named — optional; empty means "any N". */
const [pickedWagons, setPickedWagons] = useState<Set<string>>(new Set());
const createRequest = useMutation(
api.wagonTransferRequests.create.mutationOptions(),
@@ -240,8 +244,25 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
setTransferYardId(null);
setTransferQty(0);
setTransferReason("");
setPickedWagons(new Set());
}, [yardId, typeId]);
// Naming wagons IS the ask: the count follows the picks so the two can never
// disagree. Lowering the count by hand (below) trims the selection instead.
const handlePick = (next: Set<string>) => {
setPickedWagons(next);
if (next.size > 0) setTransferQty(next.size);
};
const handleQtyChange = (n: number) => {
setTransferQty(n);
// Asking for fewer than were picked would send a selection the API rejects
// (picks may not exceed quantity) — drop the extras, keeping pick order.
if (pickedWagons.size > n) {
setPickedWagons(new Set([...pickedWagons].slice(0, n)));
}
};
// Reset the whole workspace when closed.
useEffect(() => {
if (!opened) {
@@ -274,16 +295,23 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
wagonTypeId: typeId,
quantity: transferQty,
reason: transferReason,
...(pickedWagons.size > 0
? { preferredWagonIds: [...pickedWagons] }
: {}),
});
toast({
title: `Requested ${transferQty} ${typeInfo.code(typeId)} wagon(s) · ${yardName(
yardId,
)}${yardName(transferYardId)}`,
description: "OCC will pick the wagons and complete the move.",
description:
pickedWagons.size > 0
? `OCC will send the ${pickedWagons.size} wagon(s) you named where it can.`
: "OCC will pick the wagons and complete the move.",
});
setTransferQty(0);
setTransferYardId(null);
setTransferReason("");
setPickedWagons(new Set());
} catch (err) {
showError(err, "Request failed");
}
@@ -438,10 +466,36 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
ask for more than the yard can hand over. */}
<QuantityField
value={transferQty}
onChange={setTransferQty}
onChange={handleQtyChange}
max={availableCount}
/>
</div>
{/* Optional: name the exact wagons. Leaving this empty files
a plain count request and OCC picks whatever is free. */}
<div>
<Group justify="space-between" mb={4} wrap="wrap" gap={4}>
<Text size="sm" fw={500}>
Which wagons{" "}
<Text span size="xs" c="dimmed" fw={400}>
(optional)
</Text>
</Text>
<Text size="xs" c="dimmed">
{pickedWagons.size > 0
? `${pickedWagons.size} named — OCC will prioritise these`
: "Leave empty and OCC picks any available"}
</Text>
</Group>
<WagonPicker
wagons={availableWagons}
selected={pickedWagons}
onChange={handlePick}
max={availableCount}
emptyMessage="No available wagons of this type in this yard right now."
/>
</div>
<Select
label="Destination yard"
placeholder="Select destination"
@@ -464,6 +518,7 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
</div>
{transferYardId && transferQty > 0 ? (
<Card bg="var(--mantine-color-gray-0)" radius="md" padding="sm" withBorder>
<Stack gap={6}>
<Group gap={8} wrap="nowrap">
<Text size="sm" fw={600}>
{yardName(yardId!)} {total}
@@ -481,6 +536,24 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
</Text>
</Text>
</Group>
{pickedWagons.size > 0 ? (
<Group gap={4} wrap="wrap">
{availableWagons
.filter((w) => pickedWagons.has(w.id))
.map((w) => (
<Badge
key={w.id}
size="sm"
variant="light"
color="edr-green"
radius="sm"
>
{w.wagonNumber}
</Badge>
))}
</Group>
) : null}
</Stack>
</Card>
) : null}
<Button

View File

@@ -57,6 +57,10 @@ export const URL_CONSTANTS = {
BASE: "/exchange-settings",
},
AUDIT_LOGS: {
BASE: "/audit",
},
DROPDOWN_SETTINGS: {
BASE: "/dropdown-settings",
BY_ID: (id: string) => `/api/dropdown-settings/${id}`,
@@ -355,6 +359,9 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/window-rule`,
SCHEDULE_DATE: (id: string) =>
`/train-scheduling/schedules/${id}/schedule-date`,
MERGE_PREVIEW: (id: string, targetTrainId: string) =>
`/train-scheduling/schedules/${id}/merge-preview/${targetTrainId}`,
MERGE_TRAIN: (id: string) => `/train-scheduling/schedules/${id}/merge`,
CONTRACT_BOOKING_WINDOWS: (contractId: string) =>
`/train-scheduling/contracts/${contractId}/booking-windows`,
MARK_BOOKING_PAID: (bookingId: string) =>

View File

@@ -105,6 +105,7 @@ export const FREIGHT_PERMS = {
dispatch: "edr_freight_app:train_scheduling:dispatch",
markPaid: "edr_freight_app:train_scheduling:mark_paid",
expireBooking: "edr_freight_app:train_scheduling:expire_booking",
editTrainNumber: "edr_freight_app:train_scheduling:edit_train_number",
},
fleet: {
view: "edr_freight_app:fleet:view",
@@ -311,6 +312,13 @@ export const FREIGHT_PERMS = {
cancel: "edr_freight_app:warehouse_fee_invoices:cancel",
pay: "edr_freight_app:warehouse_fee_invoices:pay",
},
/**
* Audit trail. View-only — the API exposes no write routes for audit rows,
* so there is no manage/delete counterpart to grant.
*/
auditLog: {
view: "edr_freight_app:audit_log:view",
},
settings: {
fileUpload: {
view: "edr_freight_app:settings:file_upload:view",

View File

@@ -0,0 +1,340 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Badge,
Card,
Code,
Group,
Loader,
Modal,
Select,
Stack,
Table,
Text,
Tooltip,
} from "@mantine/core";
import {
usePagination,
type OnChangeFn,
type PaginationState,
} from "@edr/ui-common";
import { PageContainer, PageHeader } from "@/components/page";
import ListControls from "@/components/common/ListControls";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import {
AUDIT_METHODS,
auditLogsService,
type AuditLog,
type AuditMethod,
} from "@/services/auditLogs.service";
/** Method → badge colour. Destructive actions read as the loudest. */
const METHOD_COLORS: Record<AuditMethod, string> = {
POST: "green",
PUT: "blue",
PATCH: "yellow",
DELETE: "red",
};
const OUTCOME_OPTIONS = [
{ value: "true", label: "Succeeded" },
{ value: "false", label: "Failed" },
];
/** `YYYY-MM-DD` → inclusive ISO bounds, so a single day covers its full range. */
const startOfDay = (date: string) => `${date}T00:00:00.000Z`;
const endOfDay = (date: string) => `${date}T23:59:59.999Z`;
const formatTimestamp = (value: string) => new Date(value).toLocaleString();
const AuditLogsPage = () => {
// Server-side filters. Unlike most freight lists (which filter an
// already-fetched array via useListControls), audit_logs is append-only and
// grows without bound, so filtering and paging both happen in the API.
const [search, setSearch] = useState("");
const [dateFrom, setDateFrom] = useState<string | null>(null);
const [dateTo, setDateTo] = useState<string | null>(null);
const [type, setType] = useState<string | null>(null);
const [method, setMethod] = useState<string | null>(null);
const [outcome, setOutcome] = useState<string | null>(null);
const [selected, setSelected] = useState<AuditLog | null>(null);
const { pagination, setPagination } = usePagination({ pageIndex: 0, pageSize: 25 });
const query = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
type: type ?? undefined,
method: (method as AuditMethod | null) ?? undefined,
isSuccess: outcome === null ? undefined : outcome === "true",
// The API filters by record id; the search box is the natural place to
// paste one when tracing what happened to a specific contract/booking.
resourceId: search.trim() || undefined,
from: dateFrom ? startOfDay(dateFrom) : undefined,
to: dateTo ? endOfDay(dateTo) : undefined,
}),
[pagination, type, method, outcome, search, dateFrom, dateTo],
);
const logsQuery = useQuery({
queryKey: ["audit-logs", query],
queryFn: () => auditLogsService.list(query),
});
const typesQuery = useQuery({
queryKey: ["audit-logs", "types"],
queryFn: () => auditLogsService.types(),
});
const rows = logsQuery.data?.items ?? [];
const totalCount = logsQuery.data?.meta.total ?? 0;
const pageCount = logsQuery.data?.meta.totalPages ?? 0;
const hasFilters = Boolean(
search || dateFrom || dateTo || type || method || outcome,
);
const resetFilters = () => {
setSearch("");
setDateFrom(null);
setDateTo(null);
setType(null);
setMethod(null);
setOutcome(null);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
};
/** Any filter change must return to page 1, or the view can land out of range. */
const onFilterChange = <T,>(setter: (value: T) => void) => (value: T) => {
setter(value);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
};
// `OnChangeFn` may hand back either a new value or an updater, so both forms
// are resolved before storing.
const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {
setPagination((prev) =>
typeof updater === "function" ? updater(prev) : updater,
);
};
// `PageContainer` gives the page the same horizontal inset and vertical
// rhythm as every other dashboard screen; `fluid` lifts the max-width cap
// because the log table is wide.
return (
<PageContainer fluid>
<PageHeader
title="Audit logs"
subtitle="Every state-changing action taken by backoffice staff. Read-only — entries cannot be edited or removed."
/>
<Card withBorder padding="md">
<Stack gap="md">
<ListControls
search={search}
onSearchChange={onFilterChange(setSearch)}
searchPlaceholder="Filter by record id…"
dateFrom={dateFrom}
onDateFromChange={onFilterChange(setDateFrom)}
dateTo={dateTo}
onDateToChange={onFilterChange(setDateTo)}
dateLabel="Action date"
hasFilters={hasFilters}
onReset={resetFilters}
>
<Select
label="Entity"
placeholder="All entities"
data={typesQuery.data ?? []}
value={type}
onChange={onFilterChange(setType)}
clearable
searchable
w={200}
/>
<Select
label="Method"
placeholder="All methods"
data={[...AUDIT_METHODS]}
value={method}
onChange={onFilterChange(setMethod)}
clearable
w={150}
/>
<Select
label="Outcome"
placeholder="Any outcome"
data={OUTCOME_OPTIONS}
value={outcome}
onChange={onFilterChange(setOutcome)}
clearable
w={160}
/>
</ListControls>
{logsQuery.isLoading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : logsQuery.isError ? (
<Text c="red" ta="center" py="xl">
Could not load audit logs.
</Text>
) : rows.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
No audit entries match these filters.
</Text>
) : (
<Table.ScrollContainer minWidth={900}>
<Table highlightOnHover striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Action</Table.Th>
<Table.Th>Entity</Table.Th>
<Table.Th>Method</Table.Th>
<Table.Th>User</Table.Th>
<Table.Th>Outcome</Table.Th>
<Table.Th>When</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((log) => (
<Table.Tr
key={log.id}
onClick={() => setSelected(log)}
style={{ cursor: "pointer" }}
>
<Table.Td maw={340}>
<Text size="sm" lineClamp={2}>
{log.title}
</Text>
</Table.Td>
<Table.Td>
<Badge variant="light">{log.type}</Badge>
</Table.Td>
<Table.Td>
<Badge color={METHOD_COLORS[log.method]} variant="light">
{log.method}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">{log.userName ?? "—"}</Text>
{log.userRole ? (
<Text size="xs" c="dimmed">
{log.userRole}
</Text>
) : null}
</Table.Td>
<Table.Td>
{log.isSuccess ? (
<Badge color="green" variant="light">
Success
</Badge>
) : (
// The status code separates "denied" (403) from
// "broke" (500) — both are simply a failure here.
<Tooltip
label={log.errorMessage ?? "Failed"}
multiline
w={280}
disabled={!log.errorMessage}
>
<Badge color="red" variant="light">
Failed{log.statusCode ? ` · ${log.statusCode}` : ""}
</Badge>
</Tooltip>
)}
</Table.Td>
<Table.Td>
<Text size="sm">{formatTimestamp(log.createdAt)}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<RuleEngineListFooter
pagination={pagination}
pageCount={pageCount}
totalCount={totalCount}
itemLabel="entries"
onPaginationChange={handlePaginationChange}
/>
</Stack>
</Card>
<Modal
opened={selected !== null}
onClose={() => setSelected(null)}
title="Audit entry"
size="lg"
>
{selected ? (
<Stack gap="sm">
<DetailRow label="Action" value={selected.title} />
<DetailRow label="Entity" value={selected.type} />
<DetailRow label="Record id" value={selected.resourceId} />
<DetailRow label="Method" value={selected.method} />
<DetailRow label="URL" value={selected.url} />
<DetailRow label="Route" value={selected.routePath} />
<DetailRow
label="Outcome"
value={
selected.isSuccess
? `Success${selected.statusCode ? ` (${selected.statusCode})` : ""}`
: `Failed${selected.statusCode ? ` (${selected.statusCode})` : ""}`
}
/>
{selected.errorMessage ? (
<DetailRow label="Error" value={selected.errorMessage} />
) : null}
<DetailRow label="User" value={selected.userName} />
<DetailRow label="Role" value={selected.userRole} />
<DetailRow label="User id" value={selected.userId} />
<DetailRow label="IP address" value={selected.ipAddress} />
<DetailRow label="Request id" value={selected.requestId} />
<DetailRow
label="Duration"
value={selected.durationMs === null ? null : `${selected.durationMs} ms`}
/>
<DetailRow label="When" value={formatTimestamp(selected.createdAt)} />
<div>
<Text size="sm" fw={600} mb={4}>
Request payload
</Text>
{selected.request ? (
// Secrets are already redacted and uploads reduced to
// descriptors by the API before storage.
<Code block style={{ maxHeight: 320, overflow: "auto" }}>
{JSON.stringify(selected.request, null, 2)}
</Code>
) : (
<Text size="sm" c="dimmed">
No payload recorded.
</Text>
)}
</div>
</Stack>
) : null}
</Modal>
</PageContainer>
);
};
const DetailRow = ({ label, value }: { label: string; value: string | null }) => (
<Group gap="xs" wrap="nowrap" align="flex-start">
<Text size="sm" fw={600} w={120} style={{ flexShrink: 0 }}>
{label}
</Text>
<Text size="sm" style={{ wordBreak: "break-all" }}>
{value ?? "—"}
</Text>
</Group>
);
export default AuditLogsPage;

View File

@@ -247,7 +247,6 @@ export default function BookingRequestDetailPage() {
<Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg">
<BookingCompanyCard booking={booking} />
<BookingSchedulingWindowCard booking={booking} />
<BookingPricingSummary booking={booking} />
<Box id="warehouse-payments">
<WarehouseInfoCard
@@ -257,6 +256,11 @@ export default function BookingRequestDetailPage() {
tradeDirection={booking.tradeDirection}
/>
</Box>
{/* Sits directly above the staff actions: reviewing an operation
request means approving the booking onto a specific train, so
that train and its clock must be readable before the approve
button. */}
<BookingSchedulingWindowCard booking={booking} />
<BookingActionsToolbar
booking={booking}
mutations={mutations}

View File

@@ -10,6 +10,7 @@ import {
Progress,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { isAxiosError } from "axios";
@@ -82,6 +83,13 @@ export default function TrainBuilderDetailPage() {
const [deactivateOpen, setDeactivateOpen] = useState(false);
const [maintenanceTarget, setMaintenanceTarget] =
useState<TrainCompositionWagon | null>(null);
const [maintenanceNote, setMaintenanceNote] = useState("");
// Clearing the note with the target stops one wagon's reason being carried
// over onto the next wagon sent to maintenance.
const closeMaintenance = () => {
setMaintenanceTarget(null);
setMaintenanceNote("");
};
const { user } = useAuth();
const canAssign = hasPermission(user, FREIGHT_PERMS.trains.assignWagons);
const canChangeLocomotives = hasPermission(user, FREIGHT_PERMS.trains.changeLocomotives);
@@ -103,6 +111,19 @@ export default function TrainBuilderDetailPage() {
const activate = useMutation(api.trainBuilder.activate.mutationOptions());
const composition = compositionQuery.data;
// Staff identify a train by its operational run numbers, not the internal
// code — mirrors formatTrainRunLabel on the API, which writes the history note.
const trainRunLabel =
[
composition?.exportTrainNumber?.trim()
? `export ${composition.exportTrainNumber.trim()}`
: null,
composition?.importTrainNumber?.trim()
? `import ${composition.importTrainNumber.trim()}`
: null,
]
.filter(Boolean)
.join(" / ") || composition?.code;
const busy =
assignWagons.isPending ||
removeWagon.isPending ||
@@ -463,7 +484,7 @@ export default function TrainBuilderDetailPage() {
<Modal
opened={Boolean(maintenanceTarget)}
onClose={() => setMaintenanceTarget(null)}
onClose={closeMaintenance}
title={<Text fw={600}>Send wagon to maintenance?</Text>}
radius="lg"
centered
@@ -476,14 +497,22 @@ export default function TrainBuilderDetailPage() {
</Text>{" "}
is detached from train{" "}
<Text span fw={700} c="dark">
{composition.code}
{trainRunLabel}
</Text>{" "}
and set to MAINTENANCE it stays out of the available pool until it
clears. The detach is stamped with the time and this train number in
the wagon's history.
clears. The detach is stamped with the time and this train's run
numbers in the wagon's history.
</Text>
<Textarea
label="Note"
placeholder="Optional note (e.g. reason for maintenance)"
value={maintenanceNote}
onChange={(e) => setMaintenanceNote(e.currentTarget.value)}
autosize
minRows={2}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setMaintenanceTarget(null)}>
<Button variant="default" onClick={closeMaintenance}>
Keep in consist
</Button>
<Button
@@ -495,11 +524,12 @@ export default function TrainBuilderDetailPage() {
await maintenanceWagon.mutateAsync({
id: composition.id,
wagonId: maintenanceTarget!.id,
note: maintenanceNote.trim() || undefined,
});
toast({
title: `Wagon ${maintenanceTarget!.wagonNumber} sent to maintenance`,
});
setMaintenanceTarget(null);
closeMaintenance();
}, "Could not send wagon to maintenance")
}
>

View File

@@ -23,6 +23,7 @@ import {
CalendarClock,
CheckCircle2,
Clock,
Merge,
Container as ContainerIcon,
Eye,
FileText,
@@ -54,6 +55,7 @@ import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPl
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel";
import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal";
import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel";
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
@@ -114,6 +116,7 @@ export default function TrainScheduleV2DetailPage() {
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
const [windowSettingsOpen, setWindowSettingsOpen] = useState(false);
const [mergeModalOpen, setMergeModalOpen] = useState(false);
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
const [gatepassReference, setGatepassReference] = useState("");
const [gatepassFileUrl, setGatepassFileUrl] = useState("");
@@ -400,7 +403,6 @@ export default function TrainScheduleV2DetailPage() {
const canDispatch =
schedule.status === "SCHEDULED" &&
hasPermission(authUser, FREIGHT_PERMS.trainScheduling.dispatch);
// Dispatch readiness: bookings with no wagon, and wagon-loaded bookings whose
// cargo staff never marked loaded. Both are warnings, not blockers — staff can
// still dispatch after confirming.
@@ -667,6 +669,7 @@ export default function TrainScheduleV2DetailPage() {
assignedBookings={(schedule.bookings ?? []).map((b) => ({
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
customer: b.customer,
weightTons: b.weightTons,
isGovernment: b.isGovernment,
wagonsRequired: b.wagonsRequired,
@@ -940,7 +943,7 @@ export default function TrainScheduleV2DetailPage() {
{schedule.trainNumber ? (
<Box>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
Voyage No.
Train No.
</Text>
<Text
ff="monospace"
@@ -952,6 +955,33 @@ export default function TrainScheduleV2DetailPage() {
</Text>
</Box>
) : null}
{schedule.voyageNumber ? (
<Box>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
Voyage No.
</Text>
<Text
ff="monospace"
fw={800}
lh={1.1}
style={{ fontSize: 32, color: "#0f172a" }}
>
{schedule.voyageNumber}
</Text>
</Box>
) : null}
{/* Merging rewrites the consist, so it is offered only while
the departure can still be edited. */}
{canEditBookings ? (
<Button
variant="light"
size="compact-sm"
leftSection={<Merge size={14} />}
onClick={() => setMergeModalOpen(true)}
>
Merge
</Button>
) : null}
{schedule.direction ? (
<Box>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
@@ -1369,6 +1399,15 @@ export default function TrainScheduleV2DetailPage() {
onSaved={() => void detailQuery.refetch()}
/>
<MergeScheduleTrainModal
scheduleId={scheduleId ?? null}
currentTrainId={schedule.trainSet?.trainId ?? null}
scheduleReference={schedule.reference ?? null}
opened={mergeModalOpen}
onClose={() => setMergeModalOpen(false)}
onMerged={() => void detailQuery.refetch()}
/>
<SwitchGovernmentBookingModal
key={switchTarget?.id ?? "none"}
opened={Boolean(switchTarget)}

View File

@@ -134,6 +134,9 @@ export default function TrainScheduleV2ListPage() {
// confirmation.
const [dispatchTarget, setDispatchTarget] =
useState<TrainScheduleListItem | null>(null);
// Cancelling is likewise irreversible — confirmed before the mutation fires.
const [cancelTarget, setCancelTarget] =
useState<TrainScheduleListItem | null>(null);
const [editDateSchedule, setEditDateSchedule] =
useState<TrainScheduleListItem | null>(null);
const [routeId, setRouteId] = useState("");
@@ -424,9 +427,7 @@ export default function TrainScheduleV2ListPage() {
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<MetricChip value={row.original.bookingsCount} label="bkg" />
{/* Wagon SLOTS this schedule's bookings occupy — not the coupled
consist. A built train shows 0 here until bookings are allocated. */}
<MetricChip value={row.original.wagonCount} label="wgn used" />
<WagonChips schedule={row.original} />
<MetricChip value={`${row.original.totalWeightTons}T`} label="" subtle />
</Group>
),
@@ -503,21 +504,7 @@ export default function TrainScheduleV2ListPage() {
<Menu.Item
color="red"
leftSection={<Ban size={15} />}
onClick={async () => {
try {
await cancel.mutateAsync({
id: schedule.id,
freightType: schedule.freightType ?? "CONTAINER",
});
toast({ title: "Schedule cancelled" });
} catch (err) {
toast({
title: "Cancel failed",
description: parseError(err, "Could not cancel"),
variant: "destructive",
});
}
}}
onClick={() => setCancelTarget(schedule)}
>
Cancel schedule
</Menu.Item>
@@ -969,10 +956,118 @@ export default function TrainScheduleV2ListPage() {
</Group>
</Stack>
</Modal>
{/* Cancelling a schedule is destructive and cannot be undone, so it is
confirmed here rather than firing straight from the row menu. */}
<Modal
opened={cancelTarget != null}
onClose={() => setCancelTarget(null)}
title="Cancel this schedule?"
centered
radius="md"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
<Text span fw={600} c="dark">
{cancelTarget?.trainNumber ?? cancelTarget?.reference ?? "This train"}
</Text>{" "}
will be cancelled and removed from the active schedule board. This
cannot be undone.
</Text>
{cancelTarget?.bookingsCount ? (
<Text size="sm" c="red.7" fw={500}>
{cancelTarget.bookingsCount} booking
{cancelTarget.bookingsCount === 1 ? "" : "s"} on this train will
need to be moved to another schedule.
</Text>
) : null}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setCancelTarget(null)}>
Keep schedule
</Button>
<Button
color="red"
leftSection={<Ban size={16} />}
loading={cancel.isPending}
onClick={async () => {
if (!cancelTarget) return;
try {
await cancel.mutateAsync({
id: cancelTarget.id,
freightType: cancelTarget.freightType ?? "CONTAINER",
});
toast({ title: "Schedule cancelled" });
setCancelTarget(null);
void schedulesQuery.refetch();
} catch (err) {
toast({
title: "Cancel failed",
description: parseError(err, "Could not cancel"),
variant: "destructive",
});
}
}}
>
Cancel schedule
</Button>
</Group>
</Stack>
</Modal>
</PageContainer>
);
}
/**
* The row's wagon chips, matching the detail page's wagon plan: used is slots
* carrying a booking allocation (never the coupled consist size), and remaining
* excludes wagons reserved by bookings that have not paid yet — that space is
* claimed, so it is not bookable.
*
* Schedules whose train set has not been built yet have no consist to measure,
* so both figures fall back to the schedule's planned `maxWagons` ceiling.
* Without that fallback an unbuilt 37-wagon schedule reads "0 bookable" even
* though every one of its wagons is still free.
*/
function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
// Pre-deploy API rows carry only wagonCount; fall back so the chip still
// renders rather than reading 0 used on every train.
const total = schedule.wagonsTotal ?? schedule.wagonCount;
const used = schedule.wagonsUsed;
const reserved = schedule.wagonsReserved ?? 0;
// Until the train set is built there is no consist to measure against, so
// `wagonsRemaining` (consist minus claimed) is 0 on every unbuilt schedule —
// which reads as "fully booked" when in fact nothing is booked at all. Before
// a consist exists, capacity is the planned ceiling minus what bookings have
// already claimed.
const planCeiling = schedule.maxWagons ?? 0;
const remaining =
total === 0 && planCeiling > 0
? Math.max(0, planCeiling - Math.max(used ?? 0, reserved))
: schedule.wagonsRemaining;
if (used == null) {
return <MetricChip value={total} label="wgn" subtle />;
}
return (
<>
{/* An unbuilt consist has no "used out of coupled" to show; the plan
ceiling is the only meaningful denominator at that point. */}
<MetricChip
value={total === 0 && planCeiling > 0 ? `${used}/${planCeiling}` : `${used}/${total}`}
label={total === 0 && planCeiling > 0 ? "wgn planned" : "wgn used"}
/>
{reserved > used ? (
<MetricChip value={reserved} label="reserved" subtle />
) : null}
{remaining != null ? (
<MetricChip value={remaining} label="bookable" subtle />
) : null}
</>
);
}
function MetricChip({
value,
label,
@@ -1078,7 +1173,7 @@ function ScheduleCard({
</Group>
<Group gap={6} wrap="nowrap">
<MetricChip value={schedule.bookingsCount} label="bkg" />
<MetricChip value={schedule.wagonCount} label="wgn used" />
<WagonChips schedule={schedule} />
<MetricChip value={`${schedule.totalWeightTons}T`} label="" subtle />
</Group>
</Group>

View File

@@ -2,19 +2,18 @@ import { Freight } from "@edr/types";
import {
Alert,
Button,
Checkbox,
Group,
Loader,
Modal,
ScrollArea,
Stack,
Text,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { AlertTriangle, ArrowRight, PackageCheck } from "lucide-react";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import WagonPicker from "@/components/wagons/WagonPicker";
import { api } from "@/services/api";
import type { WagonTransferRequest } from "@/services/wagon.service";
@@ -59,23 +58,21 @@ export function TransferFulfillModal({
const fulfill = useMutation(api.wagonTransferRequests.fulfill.mutationOptions());
const canTake = useMemo(
() => Math.min(outstanding, wagons.length),
[outstanding, wagons.length],
);
/** The requester's picks that are still in this yard and still available. */
const preferredHere = useMemo(() => {
const asked = new Set(request?.preferredWagonIds ?? []);
return asked.size ? wagons.filter((w) => asked.has(w.id)) : [];
}, [request, wagons]);
const toggle = (id: string) =>
setPicked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
// Never let staff pick more than is still owed — the API rejects it too.
else if (next.size >= outstanding) return prev;
else next.add(id);
return next;
});
const missingPreferred =
(request?.preferredWagonIds?.length ?? 0) - preferredHere.length;
const takeAllAvailable = () =>
setPicked(new Set(wagons.slice(0, canTake).map((w) => w.id)));
// Open on what the requester asked for: OCC confirms rather than re-picks.
// Re-runs when the wagon list arrives, and is capped at what is still owed.
useEffect(() => {
if (!request) return;
setPicked(new Set(preferredHere.slice(0, outstanding).map((w) => w.id)));
}, [request, preferredHere, outstanding]);
const close = () => {
setPicked(new Set());
@@ -120,7 +117,6 @@ export function TransferFulfillModal({
>
{!request ? null : (
<Stack gap="sm">
<Group justify="space-between" wrap="wrap">
<Text size="sm">
<Text span fw={700}>
{outstanding}
@@ -131,16 +127,6 @@ export function TransferFulfillModal({
</Text>{" "}
available in {yardLabel(request.fromYard)}
</Text>
<Button
variant="light"
size="xs"
radius="md"
disabled={canTake === 0}
onClick={takeAllAvailable}
>
Select {canTake}
</Button>
</Group>
{wagons.length < outstanding ? (
<Alert color="yellow" radius="md" icon={<AlertTriangle size={15} />}>
@@ -150,27 +136,39 @@ export function TransferFulfillModal({
</Alert>
) : null}
{preferredHere.length > 0 ? (
<Alert color="teal" radius="md" icon={<PackageCheck size={15} />}>
The requester named{" "}
<Text span fw={700}>
{preferredHere.length}
</Text>{" "}
specific wagon(s) pre-selected below. You can change the
selection freely; the request is a count, not a reservation.
</Alert>
) : null}
{missingPreferred > 0 ? (
<Alert color="orange" radius="md" icon={<AlertTriangle size={15} />}>
{missingPreferred} of the wagon(s) the requester named{" "}
{missingPreferred === 1 ? "is" : "are"} no longer available in this
yard. Pick replacements below.
</Alert>
) : null}
{isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
) : wagons.length === 0 ? (
<Text c="dimmed" size="sm" py="md">
No available wagons of this type in the source yard right now.
</Text>
) : (
<ScrollArea.Autosize mah={320}>
<Stack gap={4}>
{wagons.map((w) => (
<Checkbox
key={w.id}
checked={picked.has(w.id)}
onChange={() => toggle(w.id)}
label={w.wagonNumber}
<WagonPicker
wagons={wagons}
selected={picked}
onChange={setPicked}
// Never let staff pick more than is still owed — the API rejects it too.
max={outstanding}
emptyMessage="No available wagons of this type in the source yard right now."
maxHeight={300}
/>
))}
</Stack>
</ScrollArea.Autosize>
)}
<Group justify="flex-end" gap="sm">

View File

@@ -15,6 +15,7 @@ import { AlertTriangle, Send, XCircle } from "lucide-react";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import WagonPicker from "@/components/wagons/WagonPicker";
import { api } from "@/services/api";
import type { WagonTransferRequest } from "@/services/wagon.service";
@@ -45,9 +46,11 @@ function useTransferOptions(enabled: boolean) {
/**
* Wagons the source yard can hand over right now — AVAILABLE and not coupled to
* a built train. Mirrors `countAvailable` on the API, which rejects any request
* asking for more than this, so the field must not let one be filed.
* asking for more than this, so the field must not let one be filed. Returns
* the wagons themselves so the form can also offer them for picking; `count` is
* null until both a yard and a type are chosen.
*/
function useAvailableCount(
function useAvailableWagons(
enabled: boolean,
fromYardId: string | null,
wagonTypeId: string | null,
@@ -56,14 +59,15 @@ function useAvailableCount(
...api.wagons.list.queryOptions({ input: {} }),
enabled: enabled && Boolean(fromYardId && wagonTypeId),
});
if (!fromYardId || !wagonTypeId) return null;
return wagons.filter(
if (!fromYardId || !wagonTypeId) return { wagons: [], count: null };
const inYard = wagons.filter(
(w) =>
w.currentYardId === fromYardId &&
w.wagonTypeId === wagonTypeId &&
w.status === Freight.WagonStatus.Available &&
!w.trainId,
).length;
);
return { wagons: inYard, count: inYard.length };
}
export interface TransferRequestFormModalProps {
@@ -95,6 +99,8 @@ export function TransferRequestFormModal({
const [wagonTypeId, setWagonTypeId] = useState<string | null>(null);
const [quantity, setQuantity] = useState<number | string>(1);
const [reason, setReason] = useState("");
/** Specific wagons the requester named — optional; empty means "any N". */
const [picked, setPicked] = useState<Set<string>>(new Set());
// Re-seed on every open so a carry-over never leaks into the next request.
useEffect(() => {
@@ -104,11 +110,27 @@ export function TransferRequestFormModal({
setWagonTypeId(prefillFrom?.wagonTypeId ?? null);
setQuantity(prefillFrom ? outstandingOn(prefillFrom) : 1);
setReason(prefillFrom?.reason ?? "");
setPicked(new Set());
}, [opened, prefillFrom]);
const create = useMutation(api.wagonTransferRequests.create.mutationOptions());
const available = useAvailableCount(opened, fromYardId, wagonTypeId);
const { wagons: availableWagons, count: available } = useAvailableWagons(
opened,
fromYardId,
wagonTypeId,
);
// The picks belong to one yard+type pair; changing either invalidates them.
useEffect(() => {
setPicked(new Set());
}, [fromYardId, wagonTypeId]);
// Naming wagons IS the ask, so the count follows the picks.
const handlePick = (next: Set<string>) => {
setPicked(next);
if (next.size > 0) setQuantity(next.size);
};
// A prefilled outstanding count (or a count typed before the yard was picked)
// can exceed what the chosen source yard actually has — pull it back down so
@@ -118,6 +140,16 @@ export function TransferRequestFormModal({
setQuantity((q) => (Number(q) > available ? available : q));
}, [available]);
// Asking for fewer than were picked would send a selection the API rejects
// (picks may not exceed quantity) — drop the extras, keeping pick order.
const handleQuantityChange = (value: number | string) => {
setQuantity(value);
const n = Number(value);
if (Number.isFinite(n) && picked.size > n) {
setPicked(new Set([...picked].slice(0, Math.max(0, n))));
}
};
const sameYard = Boolean(fromYardId && fromYardId === toYardId);
const overAvailable = available != null && Number(quantity) > available;
const valid =
@@ -136,6 +168,7 @@ export function TransferRequestFormModal({
wagonTypeId: wagonTypeId!,
quantity: Number(quantity),
reason: reason.trim(),
...(picked.size > 0 ? { preferredWagonIds: [...picked] } : {}),
});
toast.success("Transfer request filed");
onClose();
@@ -203,7 +236,7 @@ export function TransferRequestFormModal({
clampBehavior={available == null ? "none" : "strict"}
allowNegative={false}
value={quantity}
onChange={setQuantity}
onChange={handleQuantityChange}
disabled={available === 0}
error={
available === 0
@@ -214,6 +247,34 @@ export function TransferRequestFormModal({
}
required
/>
{/* Optional: name the exact wagons. Leaving this empty files a plain
count request and OCC picks whatever is free. */}
{availableWagons.length > 0 ? (
<div>
<Group justify="space-between" mb={4} wrap="wrap" gap={4}>
<Text size="sm" fw={500}>
Which wagons{" "}
<Text span size="xs" c="dimmed" fw={400}>
(optional)
</Text>
</Text>
<Text size="xs" c="dimmed">
{picked.size > 0
? `${picked.size} named — OCC will prioritise these`
: "Leave empty and OCC picks any available"}
</Text>
</Group>
<WagonPicker
wagons={availableWagons}
selected={picked}
onChange={handlePick}
max={available ?? undefined}
maxHeight={200}
/>
</div>
) : null}
<Textarea
label="Reason"
placeholder="Why the wagons are needed"

View File

@@ -52,6 +52,7 @@ import {
TransferRequestFormModal,
} from "./TransferRequestModals";
import {
PreferredWagonChips,
TransferProgress,
TransferStatusBadge,
fmtDateTime,
@@ -195,6 +196,11 @@ export default function WagonTransfersPage() {
<Text size="sm">{wagonTypeLabel(row.original.wagonType)}</Text>
),
},
{
id: "wagons",
header: () => <span>Wagons requested</span>,
cell: ({ row }) => <PreferredWagonChips request={row.original} />,
},
{
id: "progress",
header: () => <span>Delivered</span>,
@@ -564,6 +570,17 @@ export default function WagonTransfersPage() {
{wagonTypeLabel(viewingReason.wagonType)} ·{" "}
{viewingReason.quantity} wagon(s)
</Text>
{viewingReason.preferredWagons?.length ? (
<div>
<Text size="xs" c="dimmed" mb={4}>
Wagons requested
</Text>
<PreferredWagonChips
request={viewingReason}
limit={viewingReason.preferredWagons.length}
/>
</div>
) : null}
<Box
className="text-sm [&_p]:my-2 [&_ol]:list-decimal [&_ul]:list-disc [&_ol]:pl-5 [&_ul]:pl-5"
dangerouslySetInnerHTML={{

View File

@@ -3,6 +3,65 @@ import { Badge, Box, Group, Progress, Text, Tooltip } from "@mantine/core";
import type { WagonTransferRequest } from "@/services/wagon.service";
/** How many wagon chips fit a table cell before the rest are rolled up. */
const CHIP_LIMIT = 4;
/**
* The wagons the requester actually named, when they picked any. Rendered as
* chips so a glance down the column separates "send me these 3" from a plain
* count request — the two are fulfilled differently.
*/
export function PreferredWagonChips({
request,
limit = CHIP_LIMIT,
}: {
request: WagonTransferRequest;
limit?: number;
}) {
const wagons = request.preferredWagons ?? [];
if (wagons.length === 0) {
return (
<Tooltip label="No specific wagons named — OCC picks any available" withArrow>
<Text size="xs" c="dimmed">
Any {request.quantity}
</Text>
</Tooltip>
);
}
const shown = wagons.slice(0, limit);
const rest = wagons.length - shown.length;
return (
<Tooltip
withArrow
multiline
maw={280}
label={`Requested: ${wagons.map((w) => w.wagonNumber).join(", ")}`}
>
<Group gap={4} wrap="wrap" maw={220}>
{shown.map((w) => (
<Badge
key={w.id}
size="sm"
variant="light"
color="edr-green"
radius="sm"
style={{ fontVariantNumeric: "tabular-nums" }}
>
{w.wagonNumber}
</Badge>
))}
{rest > 0 ? (
<Badge size="sm" variant="outline" color="gray" radius="sm">
+{rest}
</Badge>
) : null}
</Group>
</Tooltip>
);
}
/** Reason/note fields come from a rich-text editor and store HTML — this
* gives a plain-text preview for list/table contexts (full formatting is
* shown via `sanitizeHtml` + `dangerouslySetInnerHTML` where there's room). */

View File

@@ -67,6 +67,7 @@ import type {
PinWagonsPayload,
RecordCheckpointPayload,
StaffBookingWindow,
ScheduleMergePreview,
TrainScheduleDetail,
TrainScheduleFilters,
TrainScheduleListFilters,
@@ -596,6 +597,36 @@ export const api = {
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
previewScheduleMerge: endpoint<
{ id: string; targetTrainId: string },
ScheduleMergePreview
>(
"train-scheduling",
"merge-preview",
({ id, targetTrainId }) =>
trainSchedulingService.previewScheduleMerge(id, targetTrainId),
({ id, targetTrainId }) => [
"train-scheduling",
"merge-preview",
id,
targetTrainId,
],
),
mergeScheduleTrain: endpoint<
{ id: string; targetTrainId: string; reason?: string },
TrainScheduleDetail
>(
"train-scheduling",
"merge-train",
({ id, ...payload }) =>
trainSchedulingService.mergeScheduleTrain(id, payload),
undefined,
// Wagons and bookings move between trains and schedules, so the wagon and
// train caches are stale too — not just the scheduling ones.
() => [...TRAIN_SCHEDULING_INVALIDATIONS, ["wagons"], ["trains"]],
),
markBookingPaid: endpoint<string, void>(
"train-scheduling",
"mark-booking-paid",
@@ -2040,11 +2071,14 @@ export const api = {
() => TRAIN_BUILDER_INVALIDATIONS,
),
sendWagonToMaintenance: endpoint<{ id: string; wagonId: string }, TrainComposition>(
sendWagonToMaintenance: endpoint<
{ id: string; wagonId: string; note?: string },
TrainComposition
>(
"train-builder",
"sendWagonToMaintenance",
({ id, wagonId }) =>
trainBuilderService.sendWagonToMaintenance(id, wagonId).then((r) => r.data),
({ id, wagonId, note }) =>
trainBuilderService.sendWagonToMaintenance(id, wagonId, note).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
),

View File

@@ -0,0 +1,97 @@
import type { PaginatedResponse } from "@edr/types";
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
import type { ApiResponse } from "@/types/apiResponse";
const BASE = URL_CONSTANTS.AUDIT_LOGS.BASE;
/** Methods the audit trail records. Reads are never audited. */
export const AUDIT_METHODS = ["POST", "PUT", "PATCH", "DELETE"] as const;
export type AuditMethod = (typeof AUDIT_METHODS)[number];
/**
* One recorded backoffice action.
*
* Mirrors `AuditLog` in the freight API. `userName` / `userRole` are snapshots
* taken when the action happened, not live lookups — an old row keeps the name
* and role the actor had at the time.
*/
export interface AuditLog {
id: string;
/** Readable action, e.g. "Approve contract". */
title: string;
method: AuditMethod;
/** URL as called, query string included (secrets already redacted server-side). */
url: string;
/** Route template, e.g. `/api/contracts/:id/cancel`. */
routePath: string | null;
/** Entity the action touched, e.g. "Contract". */
type: string;
isSuccess: boolean;
statusCode: number | null;
errorMessage: string | null;
userId: string | null;
userName: string | null;
userRole: string | null;
resourceId: string | null;
/** Sanitized request body; files appear as `__file` descriptors. */
request: Record<string, unknown> | null;
ipAddress: string | null;
userAgent: string | null;
requestId: string | null;
durationMs: number | null;
createdAt: string;
}
export interface AuditLogQuery {
page?: number;
pageSize?: number;
type?: string;
userId?: string;
method?: AuditMethod;
resourceId?: string;
/** Omit for "any outcome". */
isSuccess?: boolean;
/** Inclusive ISO 8601 bounds. */
from?: string;
to?: string;
}
/**
* Drop empty filters so the request carries only what the user actually set —
* an empty string would otherwise be sent and fail the API's validation.
*/
function toParams(query: AuditLogQuery): Record<string, string | number> {
const params: Record<string, string | number> = {};
if (query.page) params.page = query.page;
if (query.pageSize) params.pageSize = query.pageSize;
if (query.type) params.type = query.type;
if (query.userId) params.userId = query.userId;
if (query.method) params.method = query.method;
if (query.resourceId) params.resourceId = query.resourceId;
if (query.isSuccess !== undefined) params.isSuccess = String(query.isSuccess);
if (query.from) params.from = query.from;
if (query.to) params.to = query.to;
return params;
}
export const auditLogsService = {
/** Paginated audit history, newest first. */
list: async (query: AuditLogQuery = {}): Promise<PaginatedResponse<AuditLog>> => {
const response = await client.get<ApiResponse<PaginatedResponse<AuditLog>>>(
`${BASE}/logs`,
{ params: toParams(query) },
);
return unwrap(response.data);
},
/** Distinct entity types present, for the filter dropdown. */
types: async (): Promise<string[]> => {
const response = await client.get<ApiResponse<string[]>>(`${BASE}/types`);
return unwrap(response.data);
},
};

View File

@@ -420,11 +420,11 @@ export const bookingsService = {
uploadDeliveryOrder: async (
id: string,
file: File,
files: File[],
dates: { vesselArrivalDate: string; doCollectedDate: string },
): Promise<BookingDetail> => {
const form = new FormData();
form.append("file", file);
files.forEach((file) => form.append("files", file));
form.append("vesselArrivalDate", dates.vesselArrivalDate);
form.append("doCollectedDate", dates.doCollectedDate);
const response = await client.post(B.CLEARANCE_DELIVERY_ORDER(id), form, {
@@ -435,11 +435,11 @@ export const bookingsService = {
uploadReleaseOrder: async (
id: string,
file: File,
files: File[],
vesselDepartureDate: string,
): Promise<{ hold?: boolean; holdReason?: string }> => {
const form = new FormData();
form.append("file", file);
files.forEach((file) => form.append("files", file));
form.append("vesselDepartureDate", vesselDepartureDate);
const response = await client.post(B.CLEARANCE_RELEASE_ORDER(id), form, {
headers: { "Content-Type": "multipart/form-data" },

View File

@@ -468,11 +468,11 @@ export const contractsService = {
uploadDeliveryOrder: async (
id: string,
file: File,
files: File[],
dates: { vesselArrivalDate: string; doCollectedDate: string },
): Promise<Freight.IContract> => {
const form = new FormData();
form.append("file", file);
files.forEach((file) => form.append("files", file));
form.append("vesselArrivalDate", dates.vesselArrivalDate);
form.append("doCollectedDate", dates.doCollectedDate);
const response = await client.post(C.CLEARANCE_DELIVERY_ORDER(id), form, {
@@ -483,11 +483,11 @@ export const contractsService = {
uploadReleaseOrder: async (
id: string,
file: File,
files: File[],
vesselDepartureDate: string,
): Promise<{ contract: Freight.IContract; hold: boolean; holdReason?: string }> => {
const form = new FormData();
form.append("file", file);
files.forEach((file) => form.append("files", file));
form.append("vesselDepartureDate", vesselDepartureDate);
const response = await client.post(C.CLEARANCE_RELEASE_ORDER(id), form, {
headers: { "Content-Type": "multipart/form-data" },

View File

@@ -309,8 +309,11 @@ export const trainBuilderService = {
removeWagon: (id: string, wagonId: string) =>
apiClient.delete<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}`),
/** Detach a wagon and move it to MAINTENANCE status. */
sendWagonToMaintenance: (id: string, wagonId: string) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/maintenance`),
/** `note` is the maintenance reason — recorded with the train it came off. */
sendWagonToMaintenance: (id: string, wagonId: string, note?: string) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/maintenance`, {
note,
}),
reorderWagons: (id: string, wagonIds: string[]) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/reorder-wagons`, { wagonIds }),
/** Park the train indefinitely — only allowed with no active schedule. */

View File

@@ -29,6 +29,7 @@ import type {
PinWagonsPayload,
RecordCheckpointPayload,
StaffBookingWindow,
ScheduleMergePreview,
TrainScheduleDetail,
UpdateScheduleWindowRulePayload,
TrainScheduleFilters,
@@ -294,6 +295,29 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
/** What a merge would do — drives the confirmation modal. Read-only. */
previewScheduleMerge: async (
scheduleId: string,
targetTrainId: string,
): Promise<ScheduleMergePreview> => {
const response = await client.get<ScheduleMergePreview>(
URL_CONSTANTS.TRAIN_SCHEDULING.MERGE_PREVIEW(scheduleId, targetTrainId),
);
return unwrap(response.data);
},
/** Merge another train into this schedule. This schedule always survives. */
mergeScheduleTrain: async (
scheduleId: string,
payload: { targetTrainId: string; reason?: string },
): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.MERGE_TRAIN(scheduleId),
payload,
);
return unwrap(response.data);
},
markBookingPaid: async (bookingId: string): Promise<void> => {
await client.post(
URL_CONSTANTS.TRAIN_SCHEDULING.MARK_BOOKING_PAID(bookingId),

View File

@@ -168,6 +168,10 @@ export interface WagonTransferRequest {
closedShortByUserId?: string | null;
/** Why the wagons are needed — required for new requests, shown on the queue. */
reason?: string | null;
/** The wagons the requester hand-picked, if any. A preference, not a reservation. */
preferredWagonIds?: string[] | null;
/** Those same picks resolved to wagon numbers by the API, for display. */
preferredWagons?: Array<{ id: string; wagonNumber: string }>;
note: string | null;
fromYard?: { id: string; label?: string; code?: string } | null;
toYard?: { id: string; label?: string; code?: string } | null;
@@ -182,6 +186,8 @@ export interface CreateTransferRequestPayload {
quantity: number;
/** Mandatory: why the wagons are needed. */
reason: string;
/** Specific wagons the requester wants — at most `quantity` of them. */
preferredWagonIds?: string[];
note?: string;
}

View File

@@ -143,6 +143,12 @@ export interface BookingTrainScheduleSummary {
actualArrivalAt: string | null;
windowPhase: string | null;
paymentPhaseEndsAt: string | null;
/**
* True when this is the train the customer requested at day-commit rather
* than a confirmed allocation — what staff see while reviewing an operation
* request, before accepting puts the booking into the batch pool.
*/
isRequested?: boolean;
}
export interface BookingDetail {

View File

@@ -217,7 +217,28 @@ export interface TrainScheduleListItem {
name?: string | null;
currentYardId?: string | null;
}>;
/** Coupled consist size. NOT the used count — see `wagonsUsed`. */
wagonCount: number;
/**
* Wagon slots actually carrying a booking allocation — the same figure the
* detail page's wagon plan shows. Optional until every API deploy carries it.
*/
wagonsUsed?: number;
/** Coupled consist size; the denominator of `wagonsUsed`. */
wagonsTotal?: number;
/** Wagons claimed by bookings (including unpaid) — not bookable. */
wagonsReserved?: number;
/** Wagons still bookable: consist minus what bookings have claimed. */
wagonsRemaining?: number;
/**
* Planned wagon ceiling for the departure, set when the schedule is created.
* Independent of the coupled consist — a schedule can plan 37 wagons before a
* single one is coupled, which is why this is the bookable figure until the
* train set is built.
*/
maxWagons?: number;
/** Plan ceiling minus the coupled consist — room left to couple. */
remainingWagons?: number;
totalWeightTons: number;
totalLengthMeters: number;
bookingsCount: number;
@@ -571,6 +592,35 @@ export interface UpdateScheduleWindowRulePayload {
exportBookingLeadHours?: number;
}
/** One schedule touched by a merge, as summarised for the confirmation modal. */
export interface MergeAffectedSchedule {
id: string;
reference: string | null;
scheduledDepartureDate: string;
status: string;
}
/**
* What merging a train into a schedule would do, computed server-side so the
* modal shows exactly what the commit will perform.
*/
export interface ScheduleMergePreview {
canMerge: boolean;
/** Why the merge is refused. Empty when `canMerge` is true. */
blockers: string[];
targetTrain: { id: string; code: string; trainNumber: string | null };
wagons: { current: number; incoming: number; merged: number };
/** The same-day schedule whose bookings move here; it is then removed. */
absorbedSchedule:
| (MergeAffectedSchedule & { bookingsMoving: number })
| null;
/** Other draft/scheduled schedules on the target — they gain wagons only. */
affectedSchedules: MergeAffectedSchedule[];
/** On the target train but left alone (dispatched, cancelled, …). */
untouchedSchedules: MergeAffectedSchedule[];
sourceTrainWillDeactivate: boolean;
}
export interface TrainScheduleDetail {
id: string;
reference?: string | null;
@@ -578,6 +628,8 @@ export interface TrainScheduleDetail {
deferredBookings?: DeferredBookingRow[];
freightType?: FreightType | null;
trainNumber?: string | null;
/** Voyage (sailing) number for this departure — editable until dispatch. */
voyageNumber?: string | null;
/** Wagon cap for this departure (built-train consist size or configured limit). */
maxWagons?: number | null;
/** Built train (Train Builder) behind this departure, when scheduled by train. */
@@ -621,6 +673,8 @@ export interface TrainScheduleDetail {
trainSet?: {
id: string;
status: string;
/** The built train this set runs on — null when it has none yet. */
trainId?: string | null;
wagonCount: number;
totalWeightTons: number;
totalLengthMeters: number;

View File

@@ -156,6 +156,48 @@ export function transitPermitFileLabel(code: string, index?: number): string {
return code;
}
/** Legacy single Delivery Order code (pre multi-file uploads). */
export const LEGACY_DELIVERY_ORDER_CODE = "delivery_order";
/** Multi-file Delivery Order uploads use `delivery_order_0`, `delivery_order_1`, … */
export const DELIVERY_ORDER_FILE_PREFIX = "delivery_order_";
export function isDeliveryOrderFileCode(code: string | null | undefined): boolean {
if (!code) return false;
const lower = code.toLowerCase();
return lower === LEGACY_DELIVERY_ORDER_CODE || lower.startsWith(DELIVERY_ORDER_FILE_PREFIX);
}
export function deliveryOrderFileLabel(code: string, index?: number): string {
const lower = code.toLowerCase();
if (lower === LEGACY_DELIVERY_ORDER_CODE) return "Delivery Order";
if (lower.startsWith(DELIVERY_ORDER_FILE_PREFIX)) {
return index != null ? `Delivery Order ${index + 1}` : "Delivery Order";
}
return code;
}
/** Legacy single Release Order code (pre multi-file uploads). */
export const LEGACY_RELEASE_ORDER_CODE = "release_order";
/** Multi-file Release Order uploads use `release_order_0`, `release_order_1`, … */
export const RELEASE_ORDER_FILE_PREFIX = "release_order_";
export function isReleaseOrderFileCode(code: string | null | undefined): boolean {
if (!code) return false;
const lower = code.toLowerCase();
return lower === LEGACY_RELEASE_ORDER_CODE || lower.startsWith(RELEASE_ORDER_FILE_PREFIX);
}
export function releaseOrderFileLabel(code: string, index?: number): string {
const lower = code.toLowerCase();
if (lower === LEGACY_RELEASE_ORDER_CODE) return "Release Order";
if (lower.startsWith(RELEASE_ORDER_FILE_PREFIX)) {
return index != null ? `Release Order ${index + 1}` : "Release Order";
}
return code;
}
/** Legacy single T1 code (GL post-booking uploader). */
export const LEGACY_T1_TRANSPORT_CODE = "t1_transport_document";