mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into freight/nati-2
This commit is contained in:
639
apps/edr-freight-api/data/audit-endpoints.js
Normal file
639
apps/edr-freight-api/data/audit-endpoints.js
Normal 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 1–2 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;
|
||||||
922
apps/edr-freight-api/docs/audit-endpoints.md
Normal file
922
apps/edr-freight-api/docs/audit-endpoints.md
Normal file
@@ -0,0 +1,922 @@
|
|||||||
|
# Freight API — Mutating Endpoints (Audit Surface)
|
||||||
|
|
||||||
|
Every state-changing route in `apps/edr-freight-api` — `POST`, `PUT`, `PATCH`, `DELETE`.
|
||||||
|
This is the candidate surface for audit logging: each row is an action a user can take
|
||||||
|
that changes persisted state and therefore needs a who / what / when trail.
|
||||||
|
|
||||||
|
All paths include the global prefix `api` (`app.setGlobalPrefix("api")` in `src/main.ts`).
|
||||||
|
Titles come from each route's `@ApiOperation({ summary })`; where a route has none,
|
||||||
|
the title is derived from its handler name.
|
||||||
|
|
||||||
|
> Generated by reading the `@Post` / `@Put` / `@Patch` / `@Delete` decorators in
|
||||||
|
> `src/**/*.controller.ts`. Re-generate after adding routes so this stays complete.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Method | Count |
|
||||||
|
| --- | ---: |
|
||||||
|
| `POST` | 351 |
|
||||||
|
| `PUT` | 8 |
|
||||||
|
| `PATCH` | 72 |
|
||||||
|
| `DELETE` | 61 |
|
||||||
|
| **Total** | **492** |
|
||||||
|
|
||||||
|
Across **66** entities.
|
||||||
|
|
||||||
|
## Entity index
|
||||||
|
|
||||||
|
| Entity | Endpoints |
|
||||||
|
| --- | ---: |
|
||||||
|
| [Account](#account) | 3 |
|
||||||
|
| [AI Assist](#ai-assist) | 1 |
|
||||||
|
| [Approval Rule](#approval-rule) | 5 |
|
||||||
|
| [Booking](#booking) | 57 |
|
||||||
|
| [Cargo](#cargo) | 6 |
|
||||||
|
| [Cargo Type](#cargo-type) | 5 |
|
||||||
|
| [Company](#company) | 30 |
|
||||||
|
| [Compliance](#compliance) | 3 |
|
||||||
|
| [Consignment](#consignment) | 1 |
|
||||||
|
| [Container](#container) | 5 |
|
||||||
|
| [Container Type](#container-type) | 5 |
|
||||||
|
| [Contract](#contract) | 68 |
|
||||||
|
| [Contract Template](#contract-template) | 8 |
|
||||||
|
| [Driver](#driver) | 5 |
|
||||||
|
| [Dropdown Setting](#dropdown-setting) | 7 |
|
||||||
|
| [EIMS Invoice](#eims-invoice) | 3 |
|
||||||
|
| [Exchange Setting](#exchange-setting) | 1 |
|
||||||
|
| [Facility](#facility) | 3 |
|
||||||
|
| [Fayda Verification](#fayda-verification) | 1 |
|
||||||
|
| [File Upload Setting](#file-upload-setting) | 7 |
|
||||||
|
| [First Mile](#first-mile) | 7 |
|
||||||
|
| [Fuel](#fuel) | 1 |
|
||||||
|
| [GPS Tracking](#gps-tracking) | 3 |
|
||||||
|
| [Import Operation](#import-operation) | 9 |
|
||||||
|
| [Incident](#incident) | 3 |
|
||||||
|
| [Interchange Document](#interchange-document) | 3 |
|
||||||
|
| [Last Mile](#last-mile) | 10 |
|
||||||
|
| [Last Mile Request](#last-mile-request) | 4 |
|
||||||
|
| [Locomotive](#locomotive) | 4 |
|
||||||
|
| [Maintenance](#maintenance) | 13 |
|
||||||
|
| [Notification Inbox](#notification-inbox) | 2 |
|
||||||
|
| [Organization User](#organization-user) | 2 |
|
||||||
|
| [OTP](#otp) | 2 |
|
||||||
|
| [Password Reset](#password-reset) | 4 |
|
||||||
|
| [Payment](#payment) | 7 |
|
||||||
|
| [Priority Config](#priority-config) | 5 |
|
||||||
|
| [Priority Rule Change Request](#priority-rule-change-request) | 3 |
|
||||||
|
| [Procurement](#procurement) | 8 |
|
||||||
|
| [Rate](#rate) | 5 |
|
||||||
|
| [Rate Change Request](#rate-change-request) | 3 |
|
||||||
|
| [Route](#route) | 4 |
|
||||||
|
| [Schedule](#schedule) | 3 |
|
||||||
|
| [Service Type](#service-type) | 5 |
|
||||||
|
| [Shipping Line](#shipping-line) | 3 |
|
||||||
|
| [Signature](#signature) | 1 |
|
||||||
|
| [Support Chat](#support-chat) | 5 |
|
||||||
|
| [Support Content](#support-content) | 3 |
|
||||||
|
| [Train](#train) | 3 |
|
||||||
|
| [Train Build](#train-build) | 11 |
|
||||||
|
| [Train Schedule](#train-schedule) | 48 |
|
||||||
|
| [Transit Agent](#transit-agent) | 3 |
|
||||||
|
| [Truck Type](#truck-type) | 3 |
|
||||||
|
| [User Trade Access](#user-trade-access) | 1 |
|
||||||
|
| [Vehicle](#vehicle) | 3 |
|
||||||
|
| [Wagon](#wagon) | 8 |
|
||||||
|
| [Wagon Transfer Request](#wagon-transfer-request) | 5 |
|
||||||
|
| [Wagon Type](#wagon-type) | 3 |
|
||||||
|
| [Warehouse](#warehouse) | 12 |
|
||||||
|
| [Warehouse Fee Invoice](#warehouse-fee-invoice) | 5 |
|
||||||
|
| [Warehouse Inspection Report](#warehouse-inspection-report) | 3 |
|
||||||
|
| [Warehouse Inventory](#warehouse-inventory) | 24 |
|
||||||
|
| [Warehouse Yard](#warehouse-yard) | 2 |
|
||||||
|
| [Warehouse Zone](#warehouse-zone) | 1 |
|
||||||
|
| [Weight Limit Rule](#weight-limit-rule) | 3 |
|
||||||
|
| [Yard](#yard) | 5 |
|
||||||
|
| [Yard Distance](#yard-distance) | 3 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints by entity
|
||||||
|
|
||||||
|
### Account
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Send a verification code to a new email/phone before changing it | `POST` | `/api/me/contact/otp` | `modules/auth/account.controller.ts:26` |
|
||||||
|
| Change the account's email or phone, gated by a verification code | `PATCH` | `/api/me/contact` | `modules/auth/account.controller.ts:40` |
|
||||||
|
| Change the account's display name | `PATCH` | `/api/me/name` | `modules/auth/account.controller.ts:54` |
|
||||||
|
|
||||||
|
### AI Assist
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Mock AI: extract structured booking fields from free-text request | `POST` | `/api/ai/booking/extract` | `modules/ai/ai.controller.ts:16` |
|
||||||
|
|
||||||
|
### Approval Rule
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create an approval rule step | `POST` | `/api/approval-rules` | `modules/rule-engine/controllers/approval-rules.controller.ts:66` |
|
||||||
|
| Move an approval step up or down within its chain | `POST` | `/api/approval-rules/:id/move-order` | `modules/rule-engine/controllers/approval-rules.controller.ts:51` |
|
||||||
|
| Bulk reorder approval steps within a chain | `POST` | `/api/approval-rules/reorder` | `modules/rule-engine/controllers/approval-rules.controller.ts:43` |
|
||||||
|
| Update an approval rule | `PATCH` | `/api/approval-rules/:id` | `modules/rule-engine/controllers/approval-rules.controller.ts:73` |
|
||||||
|
| Soft-delete an approval rule | `DELETE` | `/api/approval-rules/:id` | `modules/rule-engine/controllers/approval-rules.controller.ts:80` |
|
||||||
|
|
||||||
|
### Booking
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create a new freight booking (DRAFT) | `POST` | `/api/bookings` | `modules/bookings/bookings.controller.ts:170` |
|
||||||
|
| Allocate containers to vehicles | `POST` | `/api/bookings/:bookingId/allocate-containers` | `modules/bookings/booking-allocation.controller.ts:13` |
|
||||||
|
| Cancel booking | `POST` | `/api/bookings/:id/cancel` | `modules/bookings/bookings.controller.ts:1544` |
|
||||||
|
| Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED); | `POST` | `/api/bookings/:id/cancel-hold` | `modules/bookings/bookings.controller.ts:1568` |
|
||||||
|
| GL ET uploads customs declaration on booking (GENERAL customs) | `POST` | `/api/bookings/:id/clearance/declaration` | `modules/bookings/bookings.controller.ts:1126` |
|
||||||
|
| Upload Booking Delivery Order | `POST` | `/api/bookings/:id/clearance/delivery-order` | `modules/bookings/bookings.controller.ts:1268` |
|
||||||
|
| Customer uploads clearance documents (fieldname = document key) | `POST` | `/api/bookings/:id/clearance/documents` | `modules/bookings/bookings.controller.ts:959` |
|
||||||
|
| GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review | `POST` | `/api/bookings/:id/clearance/draft-declaration` | `modules/bookings/bookings.controller.ts:1175` |
|
||||||
|
| Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia | `POST` | `/api/bookings/:id/clearance/draft-declaration/accept` | `modules/bookings/bookings.controller.ts:1200` |
|
||||||
|
| Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable) | `POST` | `/api/bookings/:id/clearance/draft-declaration/change` | `modules/bookings/bookings.controller.ts:1211` |
|
||||||
|
| GL ET sets duty/tax on booking with notice attachment | `POST` | `/api/bookings/:id/clearance/duty` | `modules/bookings/bookings.controller.ts:1144` |
|
||||||
|
| Customer uploads duty/tax payment slip on booking | `POST` | `/api/bookings/:id/clearance/duty-slip` | `modules/bookings/bookings.controller.ts:1238` |
|
||||||
|
| Confirm Booking Export Release | `POST` | `/api/bookings/:id/clearance/export-release` | `modules/bookings/bookings.controller.ts:1326` |
|
||||||
|
| GL finalizes clearance (requires 100% approved) → CLEARANCE_READY | `POST` | `/api/bookings/:id/clearance/finalize` | `modules/bookings/bookings.controller.ts:1087` |
|
||||||
|
| GL ET finalizes import pre-clearance on booking | `POST` | `/api/bookings/:id/clearance/finalize-pre-clearance` | `modules/bookings/bookings.controller.ts:1230` |
|
||||||
|
| GL uploads customs output documents (IM4/EX3/…) | `POST` | `/api/bookings/:id/clearance/output-documents` | `modules/bookings/bookings.controller.ts:1071` |
|
||||||
|
| Customer requests operation with a schedule day | `POST` | `/api/bookings/:id/clearance/proceed` | `modules/bookings/bookings.controller.ts:979` |
|
||||||
|
| Upload Booking Release Order | `POST` | `/api/bookings/:id/clearance/release-order` | `modules/bookings/bookings.controller.ts:1288` |
|
||||||
|
| GL reviews a clearance document (Approve | Query) | `POST` | `/api/bookings/:id/clearance/review` | `modules/bookings/bookings.controller.ts:1051` |
|
||||||
|
| Request Booking RO Amendment | `POST` | `/api/bookings/:id/clearance/ro-amendment` | `modules/bookings/bookings.controller.ts:1311` |
|
||||||
|
| GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns | `POST` | `/api/bookings/:id/clearance/transit-assignee/assign` | `modules/bookings/bookings.controller.ts:1112` |
|
||||||
|
| GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration | `POST` | `/api/bookings/:id/clearance/transit-assignee/request` | `modules/bookings/bookings.controller.ts:1098` |
|
||||||
|
| Upload Booking Transit Permit | `POST` | `/api/bookings/:id/clearance/transit-permit` | `modules/bookings/bookings.controller.ts:1251` |
|
||||||
|
| Confirm submit after price change | `POST` | `/api/bookings/:id/confirm-submit` | `modules/bookings/bookings.controller.ts:906` |
|
||||||
|
| Request freight consolidation | `POST` | `/api/bookings/:id/consolidation` | `modules/bookings/bookings.controller.ts:1583` |
|
||||||
|
| Generate contract PDF from template | `POST` | `/api/bookings/:id/contract/generate` | `modules/bookings/bookings.controller.ts:1406` |
|
||||||
|
| Apply digital signature (customer or staff) | `POST` | `/api/bookings/:id/contract/sign` | `modules/bookings/bookings.controller.ts:1452` |
|
||||||
|
| Customer cancels their own booking before payment — no cancellation fee | `POST` | `/api/bookings/:id/customer-cancel` | `modules/bookings/bookings.controller.ts:1555` |
|
||||||
|
| Customer assigns external truck and driver for terminal pickup | `POST` | `/api/bookings/:id/customer-truck-assignment` | `modules/bookings/bookings.controller.ts:460` |
|
||||||
|
| Add a customer self-haul truck carrying 1–2 of the booking containers | `POST` | `/api/bookings/:id/customer-trucks` | `modules/bookings/bookings.controller.ts:686` |
|
||||||
|
| Register an import truck leaving: containers loaded + weighed gross (staff) | `POST` | `/api/bookings/:id/customer-trucks/:assignmentId/depart` | `modules/bookings/bookings.controller.ts:788` |
|
||||||
|
| Truck_dispatch: load selected containers onto a truck (staff) | `POST` | `/api/bookings/:id/customer-trucks/:assignmentId/load` | `modules/bookings/bookings.controller.ts:773` |
|
||||||
|
| Bulk add customer trucks from array payload (Excel parsed) | `POST` | `/api/bookings/:id/customer-trucks/bulk` | `modules/bookings/bookings.controller.ts:701` |
|
||||||
|
| Customer digital signature (deprecated — use POST contract/sign) | `POST` | `/api/bookings/:id/customer/sign` | `modules/bookings/bookings.controller.ts:1487` |
|
||||||
|
| Upload documents for a booking (DRAFT only) | `POST` | `/api/bookings/:id/documents` | `modules/bookings/bookings.controller.ts:869` |
|
||||||
|
| Generate a GRN over the received containers (all received, or a subset) — one GRN per batch | `POST` | `/api/bookings/:id/generate-grn` | `modules/bookings/bookings.controller.ts:820` |
|
||||||
|
| Generate price preview (DRAFT or CHANGES_REQUESTED) | `POST` | `/api/bookings/:id/generate-price` | `modules/bookings/bookings.controller.ts:882` |
|
||||||
|
| Expedite government booking to PAID / ELIGIBLE for scheduling | `POST` | `/api/bookings/:id/government-expedite` | `modules/bookings/bookings.controller.ts:1390` |
|
||||||
|
| Staff contract signature and fully execute (use contract/sign STAFF preferred) | `POST` | `/api/bookings/:id/marketing/approve` | `modules/bookings/bookings.controller.ts:1505` |
|
||||||
|
| Operations reviews an operation request: ACCEPT (→ batch pool), | `POST` | `/api/bookings/:id/operation/review` | `modules/bookings/bookings.controller.ts:1030` |
|
||||||
|
| Mark completed | `POST` | `/api/bookings/:id/operations/complete` | `modules/bookings/bookings.controller.ts:1536` |
|
||||||
|
| Mark in transit | `POST` | `/api/bookings/:id/operations/start-transit` | `modules/bookings/bookings.controller.ts:1528` |
|
||||||
|
| Customer reject price estimate | `POST` | `/api/bookings/:id/reject` | `modules/bookings/bookings.controller.ts:918` |
|
||||||
|
| Staff accept intake → set contract validity window + start approval chain | `POST` | `/api/bookings/:id/staff/accept` | `modules/bookings/bookings.controller.ts:1355` |
|
||||||
|
| Staff final reject | `POST` | `/api/bookings/:id/staff/reject` | `modules/bookings/bookings.controller.ts:1374` |
|
||||||
|
| Staff return booking for customer updates | `POST` | `/api/bookings/:id/staff/request-changes` | `modules/bookings/bookings.controller.ts:1339` |
|
||||||
|
| Customer submit booking | `POST` | `/api/bookings/:id/submit` | `modules/bookings/bookings.controller.ts:894` |
|
||||||
|
| Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles | `POST` | `/api/bookings/:id/wagon-cancellations` | `modules/bookings/bookings.controller.ts:558` |
|
||||||
|
| Preview the fee/credit of a partial wagon cancellation (no writes) | `POST` | `/api/bookings/:id/wagon-cancellations/preview` | `modules/bookings/bookings.controller.ts:544` |
|
||||||
|
| 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` | `/api/bookings/wagon-cancellations/:cancellationId/rebook` | `modules/bookings/bookings.controller.ts:638` |
|
||||||
|
| Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission) | `POST` | `/api/bookings/wagon-cancellations/:cancellationId/withdraw` | `modules/bookings/bookings.controller.ts:624` |
|
||||||
|
| Update booking | `PATCH` | `/api/bookings/:id` | `modules/bookings/bookings.controller.ts:211` |
|
||||||
|
| Edit a not-yet-arrived customer truck (plate/driver/type + containers) | `PATCH` | `/api/bookings/:id/customer-trucks/:assignmentId` | `modules/bookings/bookings.controller.ts:716` |
|
||||||
|
| Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first | `PATCH` | `/api/bookings/:id/export-handover-mode` | `modules/bookings/bookings.controller.ts:761` |
|
||||||
|
| Soft-delete DRAFT booking | `DELETE` | `/api/bookings/:id` | `modules/bookings/bookings.controller.ts:861` |
|
||||||
|
| Remove consolidation pairing | `DELETE` | `/api/bookings/:id/consolidation` | `modules/bookings/bookings.controller.ts:1590` |
|
||||||
|
| Remove a not-yet-arrived customer truck from a booking | `DELETE` | `/api/bookings/:id/customer-trucks/:assignmentId` | `modules/bookings/bookings.controller.ts:732` |
|
||||||
|
|
||||||
|
### Cargo
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create a new cargo | `POST` | `/api/cargoes` | `modules/cargoes/cargoes.controller.ts:34` |
|
||||||
|
| Mark cargo as delivered | `POST` | `/api/cargoes/:id/deliver` | `modules/cargoes/cargoes.controller.ts:81` |
|
||||||
|
| Load cargo into a container | `POST` | `/api/cargoes/:id/load` | `modules/cargoes/cargoes.controller.ts:67` |
|
||||||
|
| Unload cargo from container | `POST` | `/api/cargoes/:id/unload` | `modules/cargoes/cargoes.controller.ts:74` |
|
||||||
|
| Update a cargo | `PATCH` | `/api/cargoes/:id` | `modules/cargoes/cargoes.controller.ts:53` |
|
||||||
|
| Delete a cargo | `DELETE` | `/api/cargoes/:id` | `modules/cargoes/cargoes.controller.ts:60` |
|
||||||
|
|
||||||
|
### Cargo Type
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create a cargo type | `POST` | `/api/cargo-types` | `modules/rule-engine/controllers/cargo-types.controller.ts:51` |
|
||||||
|
| Move a cargo type up or down in display order | `POST` | `/api/cargo-types/:id/move-order` | `modules/rule-engine/controllers/cargo-types.controller.ts:36` |
|
||||||
|
| Bulk reorder cargo types by ID list | `POST` | `/api/cargo-types/reorder` | `modules/rule-engine/controllers/cargo-types.controller.ts:28` |
|
||||||
|
| Update a cargo type | `PATCH` | `/api/cargo-types/:id` | `modules/rule-engine/controllers/cargo-types.controller.ts:58` |
|
||||||
|
| Soft-delete a cargo type | `DELETE` | `/api/cargo-types/:id` | `modules/rule-engine/controllers/cargo-types.controller.ts:65` |
|
||||||
|
|
||||||
|
### Company
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter) | `POST` | `/api/companies` | `modules/companies/companies.controller.ts:565` |
|
||||||
|
| Upload documents for a company (onboarding) | `POST` | `/api/companies/:companyId/documents` | `modules/companies/companies.controller.ts:728` |
|
||||||
|
| Add a profile (employee) to a company | `POST` | `/api/companies/:companyId/profiles` | `modules/companies/companies.controller.ts:843` |
|
||||||
|
| Approve a pending profile change request (applies the changes) | `POST` | `/api/companies/change-requests/:id/approve` | `modules/companies/companies.controller.ts:790` |
|
||||||
|
| Reject a pending profile change request with a note | `POST` | `/api/companies/change-requests/:id/reject` | `modules/companies/companies.controller.ts:806` |
|
||||||
|
| Ask for specific changes on a pending request without rejecting it (row stays open, next edit appends to it) | `POST` | `/api/companies/change-requests/:id/request-changes` | `modules/companies/companies.controller.ts:824` |
|
||||||
|
| Create a single operational profile for the current user's company. The role starts pending and does not become the active mode | `POST` | `/api/companies/company-profile` | `modules/companies/companies.controller.ts:272` |
|
||||||
|
| Add operational profile(s) (importer/exporter/forwarder) to the current user's company | `POST` | `/api/companies/company-profiles` | `modules/companies/companies.controller.ts:229` |
|
||||||
|
| Add business-license document(s) to a profile. For an approved company | `POST` | `/api/companies/company-profiles/:profileId/license` | `modules/companies/companies.controller.ts:290` |
|
||||||
|
| Replace a business-license file with a newly uploaded one (staged for | `POST` | `/api/companies/company-profiles/:profileId/license/:fileId/replace` | `modules/companies/companies.controller.ts:311` |
|
||||||
|
| Resubmit a rejected operational role for approval (→ pending) | `POST` | `/api/companies/company-profiles/:profileId/reapply` | `modules/companies/companies.controller.ts:165` |
|
||||||
|
| Create a company with its associated external profile (onboarding) | `POST` | `/api/companies/create` | `modules/companies/companies.controller.ts:539` |
|
||||||
|
| Ask the customer to correct one uploaded document | `POST` | `/api/companies/documents/:fileId/request-change` | `modules/companies/companies.controller.ts:699` |
|
||||||
|
| Fetch company info from eTrade by TIN | `POST` | `/api/companies/fetch-etrade-info` | `modules/companies/companies.controller.ts:197` |
|
||||||
|
| Bind a completed Fayda verification to the company's owner or Power of Attorney | `POST` | `/api/companies/identity/fayda/complete` | `modules/companies/companies.controller.ts:414` |
|
||||||
|
| Declare the General Manager is the company's owner, copying the owner's verified identity across | `POST` | `/api/companies/identity/gm/same-as-owner` | `modules/companies/companies.controller.ts:432` |
|
||||||
|
| Declare the Power of Attorney is the company's owner, copying the owner's identity across | `POST` | `/api/companies/identity/poa/same-as-owner` | `modules/companies/companies.controller.ts:461` |
|
||||||
|
| Mark the current user's onboarding as complete | `POST` | `/api/companies/onboarding/complete` | `modules/companies/companies.controller.ts:527` |
|
||||||
|
| Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally | `POST` | `/api/companies/onboarding/start` | `modules/companies/companies.controller.ts:246` |
|
||||||
|
| Upload the Power of Attorney delegation letter, replacing any existing one | `POST` | `/api/companies/poa-delegation` | `modules/companies/companies.controller.ts:380` |
|
||||||
|
| Update a company | `PATCH` | `/api/companies/:id` | `modules/companies/companies.controller.ts:613` |
|
||||||
|
| Update a company profile's approval status | `PATCH` | `/api/companies/company-profiles/:profileId/status` | `modules/companies/companies.controller.ts:748` |
|
||||||
|
| Persist the user's current onboarding wizard step | `PATCH` | `/api/companies/onboarding-step` | `modules/companies/companies.controller.ts:504` |
|
||||||
|
| Update profile (flattened settings page) | `PATCH` | `/api/companies/profile` | `modules/companies/companies.controller.ts:219` |
|
||||||
|
| Soft-delete a company | `DELETE` | `/api/companies/:id` | `modules/companies/companies.controller.ts:634` |
|
||||||
|
| Remove a business-license file (staged for review on an approved company) | `DELETE` | `/api/companies/company-profiles/:profileId/license/:fileId` | `modules/companies/companies.controller.ts:338` |
|
||||||
|
| Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together | `DELETE` | `/api/companies/identity/fayda/poa` | `modules/companies/companies.controller.ts:491` |
|
||||||
|
| Clear the General Manager's identity — the \"same as owner\" declaration or a verification, and the details either wrote | `DELETE` | `/api/companies/identity/gm` | `modules/companies/companies.controller.ts:448` |
|
||||||
|
| 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` | `/api/companies/identity/poa/same-as-owner` | `modules/companies/companies.controller.ts:478` |
|
||||||
|
| Remove the Power of Attorney delegation letter (staged for review on an approved company) | `DELETE` | `/api/companies/poa-delegation/:fileId` | `modules/companies/companies.controller.ts:401` |
|
||||||
|
|
||||||
|
### Compliance
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create a compliance record | `POST` | `/api/compliance` | `modules/compliance/compliance.controller.ts:23` |
|
||||||
|
| Update a compliance record | `PATCH` | `/api/compliance/:id` | `modules/compliance/compliance.controller.ts:51` |
|
||||||
|
| Soft-delete a compliance record | `DELETE` | `/api/compliance/:id` | `modules/compliance/compliance.controller.ts:58` |
|
||||||
|
|
||||||
|
### Consignment
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create a new consignment | `POST` | `/api/consignments` | `modules/consignments/consignments.controller.ts:29` |
|
||||||
|
|
||||||
|
### Container
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create a new container | `POST` | `/api/containers` | `modules/container-management/containers.controller.ts:33` |
|
||||||
|
| Assign container to a wagon | `POST` | `/api/containers/:id/assign-wagon` | `modules/container-management/containers.controller.ts:66` |
|
||||||
|
| Unassign container from wagon | `POST` | `/api/containers/:id/unassign-wagon` | `modules/container-management/containers.controller.ts:73` |
|
||||||
|
| Update a container | `PATCH` | `/api/containers/:id` | `modules/container-management/containers.controller.ts:52` |
|
||||||
|
| Delete a container | `DELETE` | `/api/containers/:id` | `modules/container-management/containers.controller.ts:59` |
|
||||||
|
|
||||||
|
### Container Type
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create a container type | `POST` | `/api/container-types` | `modules/rule-engine/controllers/container-types.controller.ts:51` |
|
||||||
|
| Move a container type up or down in display order | `POST` | `/api/container-types/:id/move-order` | `modules/rule-engine/controllers/container-types.controller.ts:36` |
|
||||||
|
| Bulk reorder container types by ID list | `POST` | `/api/container-types/reorder` | `modules/rule-engine/controllers/container-types.controller.ts:28` |
|
||||||
|
| Update a container type | `PATCH` | `/api/container-types/:id` | `modules/rule-engine/controllers/container-types.controller.ts:58` |
|
||||||
|
| Soft-delete a container type | `DELETE` | `/api/container-types/:id` | `modules/rule-engine/controllers/container-types.controller.ts:65` |
|
||||||
|
|
||||||
|
### Contract
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create a new contract (DRAFT) with routes + cargo scope | `POST` | `/api/contracts` | `modules/contracts/contracts.controller.ts:188` |
|
||||||
|
| Approve one approval step in sequence | `POST` | `/api/contracts/:id/approval-steps/:stepId/approve` | `modules/contracts/contracts.controller.ts:552` |
|
||||||
|
| Reject one approval step — to the customer (terminal → REJECTED) or, via returnToStepId, back to an earlier approver (chain re-runs from there) | `POST` | `/api/contracts/:id/approval-steps/:stepId/reject` | `modules/contracts/contracts.controller.ts:571` |
|
||||||
|
| Customer submits a shipment request on a GENERAL customs contract | `POST` | `/api/contracts/:id/booking-requests` | `modules/contracts/contracts.controller.ts:170` |
|
||||||
|
| Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia) | `POST` | `/api/contracts/:id/bookings` | `modules/contracts/contracts.controller.ts:1076` |
|
||||||
|
| Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing | `POST` | `/api/contracts/:id/bookings/:bookingId/complete` | `modules/contracts/contracts.controller.ts:1133` |
|
||||||
|
| 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` | `/api/contracts/:id/bookings/initiate` | `modules/contracts/contracts.controller.ts:1105` |
|
||||||
|
| Customer cancels their own contract (blocked while a booking is live) | `POST` | `/api/contracts/:id/cancel` | `modules/contracts/contracts.controller.ts:526` |
|
||||||
|
| GL ET uploads customs declaration documents (multi-file) | `POST` | `/api/contracts/:id/clearance/declaration` | `modules/contracts/contracts.controller.ts:795` |
|
||||||
|
| GL DJ uploads Delivery Order (import) with vessel arrival + DO collected dates | `POST` | `/api/contracts/:id/clearance/delivery-order` | `modules/contracts/contracts.controller.ts:957` |
|
||||||
|
| Customer uploads clearance documents (fieldname = document key) | `POST` | `/api/contracts/:id/clearance/documents` | `modules/contracts/contracts.controller.ts:734` |
|
||||||
|
| 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` | `/api/contracts/:id/clearance/documents/:fileKey/replace` | `modules/contracts/contracts.controller.ts:894` |
|
||||||
|
| GL ET sets duty/tax requirement and advises amount with notice attachment | `POST` | `/api/contracts/:id/clearance/duty` | `modules/contracts/contracts.controller.ts:808` |
|
||||||
|
| Customer uploads duty/tax payment slip on contract | `POST` | `/api/contracts/:id/clearance/duty-slip` | `modules/contracts/contracts.controller.ts:932` |
|
||||||
|
| Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable) | `POST` | `/api/contracts/:id/clearance/duty/dispute` | `modules/contracts/contracts.controller.ts:918` |
|
||||||
|
| GL ET confirms export release after declaration | `POST` | `/api/contracts/:id/clearance/export-release` | `modules/contracts/contracts.controller.ts:1008` |
|
||||||
|
| GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING (legacy) | `POST` | `/api/contracts/:id/clearance/finalize` | `modules/contracts/contracts.controller.ts:788` |
|
||||||
|
| GL ET finalizes export clearance after post-booking transit permit upload | `POST` | `/api/contracts/:id/clearance/finalize-export-clearance` | `modules/contracts/contracts.controller.ts:1018` |
|
||||||
|
| GL ET finalizes import pre-clearance — unlocks Djibouti DO upload | `POST` | `/api/contracts/:id/clearance/finalize-pre-clearance` | `modules/contracts/contracts.controller.ts:838` |
|
||||||
|
| Operations finalizes self-clearance → customer may create the booking | `POST` | `/api/contracts/:id/clearance/ops-finalize` | `modules/contracts/contracts.controller.ts:1051` |
|
||||||
|
| Operations reviews a customer self-clearance document (Approve | Query) | `POST` | `/api/contracts/:id/clearance/ops-review` | `modules/contracts/contracts.controller.ts:1032` |
|
||||||
|
| GL uploads customs output documents (IM4/EX3/…) pre-booking | `POST` | `/api/contracts/:id/clearance/output-documents` | `modules/contracts/contracts.controller.ts:776` |
|
||||||
|
| GL DJ uploads Release Order + vessel departure date (export) | `POST` | `/api/contracts/:id/clearance/release-order` | `modules/contracts/contracts.controller.ts:978` |
|
||||||
|
| GL ET reviews a clearance document (Approve | Query) | `POST` | `/api/contracts/:id/clearance/review` | `modules/contracts/contracts.controller.ts:756` |
|
||||||
|
| GL DJ requests port amendment when RO vessel window is too short | `POST` | `/api/contracts/:id/clearance/ro-amendment` | `modules/contracts/contracts.controller.ts:997` |
|
||||||
|
| GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns | `POST` | `/api/contracts/:id/clearance/transit-assignee/assign` | `modules/contracts/contracts.controller.ts:863` |
|
||||||
|
| GL ET asks GL Djibouti to name the transit officer — required before the customs declaration | `POST` | `/api/contracts/:id/clearance/transit-assignee/request` | `modules/contracts/contracts.controller.ts:845` |
|
||||||
|
| GL ET uploads import transit permit documents (multi-file) | `POST` | `/api/contracts/:id/clearance/transit-permit` | `modules/contracts/contracts.controller.ts:944` |
|
||||||
|
| Confirm submit after a price change | `POST` | `/api/contracts/:id/confirm-submit` | `modules/contracts/contracts.controller.ts:380` |
|
||||||
|
| Generate contract document → CONTRACT_READY | `POST` | `/api/contracts/:id/contract/generate` | `modules/contracts/contracts.controller.ts:596` |
|
||||||
|
| Send the sudo-mode signing OTP to the contract company's registered phone (server picks the number) | `POST` | `/api/contracts/:id/contract/send-signing-otp` | `modules/contracts/contracts.controller.ts:667` |
|
||||||
|
| Apply digital signature (customer or staff/director/ceo) | `POST` | `/api/contracts/:id/contract/sign` | `modules/contracts/contracts.controller.ts:680` |
|
||||||
|
| Upload intake documents for a contract (DRAFT only) | `POST` | `/api/contracts/:id/documents` | `modules/contracts/contracts.controller.ts:354` |
|
||||||
|
| Generate unit-rate breakdown (no totals at contract phase) | `POST` | `/api/contracts/:id/generate-price` | `modules/contracts/contracts.controller.ts:366` |
|
||||||
|
| GL marks a pre-booking (contract) milestone complete | `POST` | `/api/contracts/:id/milestones/:code/complete` | `modules/contracts/contracts.controller.ts:1222` |
|
||||||
|
| Create a renewal draft linked via renewalOfId | `POST` | `/api/contracts/:id/renew` | `modules/contracts/contracts.controller.ts:705` |
|
||||||
|
| Staff lift a suspension — contract returns to its prior status | `POST` | `/api/contracts/:id/resume` | `modules/contracts/contracts.controller.ts:510` |
|
||||||
|
| Staff accept → set validity window + start approval chain | `POST` | `/api/contracts/:id/staff/accept` | `modules/contracts/contracts.controller.ts:387` |
|
||||||
|
| Staff reject contract | `POST` | `/api/contracts/:id/staff/reject` | `modules/contracts/contracts.controller.ts:476` |
|
||||||
|
| Staff return contract for customer updates | `POST` | `/api/contracts/:id/staff/request-changes` | `modules/contracts/contracts.controller.ts:460` |
|
||||||
|
| Customer submit contract (freezes contract_rate_snapshots) | `POST` | `/api/contracts/:id/submit` | `modules/contracts/contracts.controller.ts:373` |
|
||||||
|
| Staff freeze a signed contract (reversible, any post-signature step) | `POST` | `/api/contracts/:id/suspend` | `modules/contracts/contracts.controller.ts:492` |
|
||||||
|
| 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` | `/api/contracts/:id/validate-shipment` | `modules/contracts/contracts.controller.ts:1162` |
|
||||||
|
| GL marks a shipment request accepted + links the created booking | `POST` | `/api/contracts/booking-requests/:reqId/accept` | `modules/contracts/contracts.controller.ts:134` |
|
||||||
|
| Customer cancels their own pending shipment request | `POST` | `/api/contracts/booking-requests/:reqId/cancel` | `modules/contracts/contracts.controller.ts:160` |
|
||||||
|
| GL rejects a shipment request | `POST` | `/api/contracts/booking-requests/:reqId/reject` | `modules/contracts/contracts.controller.ts:149` |
|
||||||
|
| GL uploads post-booking operational documents (DO/RO/T1/…) | `POST` | `/api/contracts/bookings/:bookingId/documents` | `modules/contracts/contracts.controller.ts:1441` |
|
||||||
|
| GL ET advises duty & tax amount + declaration serial | `POST` | `/api/contracts/bookings/:bookingId/duty` | `modules/contracts/contracts.controller.ts:1260` |
|
||||||
|
| Customer uploads the duty/tax payment slip | `POST` | `/api/contracts/bookings/:bookingId/duty-slip` | `modules/contracts/contracts.controller.ts:1455` |
|
||||||
|
| GL DJ raises the post-offload final invoice (amount + invoice document) | `POST` | `/api/contracts/bookings/:bookingId/final-invoice` | `modules/contracts/contracts.controller.ts:1332` |
|
||||||
|
| Customer attaches the payment slip for the final invoice | `POST` | `/api/contracts/bookings/:bookingId/final-invoice-slip` | `modules/contracts/contracts.controller.ts:1374` |
|
||||||
|
| Customer approves the drafted final invoice — unlocks the payment slip | `POST` | `/api/contracts/bookings/:bookingId/final-invoice/approve` | `modules/contracts/contracts.controller.ts:1359` |
|
||||||
|
| GL (ET or DJ) confirms the payment slip — settles the final invoice | `POST` | `/api/contracts/bookings/:bookingId/final-invoice/confirm` | `modules/contracts/contracts.controller.ts:1386` |
|
||||||
|
| GL DJ logs a cargo exception with photo evidence | `POST` | `/api/contracts/bookings/:bookingId/incidents` | `modules/contracts/contracts.controller.ts:1479` |
|
||||||
|
| GL / Ops / Terminal marks a post-booking milestone complete | `POST` | `/api/contracts/bookings/:bookingId/milestones/:code/complete` | `modules/contracts/contracts.controller.ts:1205` |
|
||||||
|
| GL ET assigns a customs risk level (GREEN/YELLOW/RED) | `POST` | `/api/contracts/bookings/:bookingId/risk` | `modules/contracts/contracts.controller.ts:1241` |
|
||||||
|
| GL ET advises (or skips) the post-arrival additional duty/tax round (import) | `POST` | `/api/contracts/bookings/:bookingId/second-duty` | `modules/contracts/contracts.controller.ts:1399` |
|
||||||
|
| Customer attaches the additional duty/tax payment slip | `POST` | `/api/contracts/bookings/:bookingId/second-duty-slip` | `modules/contracts/contracts.controller.ts:1429` |
|
||||||
|
| GL station manager routes the shipment + binds staff | `POST` | `/api/contracts/bookings/:bookingId/station-assign` | `modules/contracts/contracts.controller.ts:1276` |
|
||||||
|
| Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export) | `POST` | `/api/contracts/bookings/:bookingId/t1-close` | `modules/contracts/contracts.controller.ts:1316` |
|
||||||
|
| GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs | `POST` | `/api/contracts/bookings/:bookingId/t1-documents` | `modules/contracts/contracts.controller.ts:1301` |
|
||||||
|
| GL ET uploads export transit permit documents (multi-file) | `POST` | `/api/contracts/bookings/:bookingId/transport-document` | `modules/contracts/contracts.controller.ts:1289` |
|
||||||
|
| Share a document with the other GL desk | `POST` | `/api/gl-exchange/:entityId` | `modules/contracts/gl-exchange.controller.ts:59` |
|
||||||
|
| Edit this contract\'s document articles only (per-contract; never touches the six shared templates) | `PUT` | `/api/contracts/:id/document/articles` | `modules/contracts/contracts.controller.ts:441` |
|
||||||
|
| Update contract | `PATCH` | `/api/contracts/:id` | `modules/contracts/contracts.controller.ts:327` |
|
||||||
|
| Uploader edits a shared document (title, visibility, file) | `PATCH` | `/api/gl-exchange/documents/:documentId` | `modules/contracts/gl-exchange.controller.ts:79` |
|
||||||
|
| Soft-delete DRAFT contract | `DELETE` | `/api/contracts/:id` | `modules/contracts/contracts.controller.ts:346` |
|
||||||
|
| Uploader removes a shared document | `DELETE` | `/api/gl-exchange/documents/:documentId` | `modules/contracts/gl-exchange.controller.ts:105` |
|
||||||
|
|
||||||
|
### Contract Template
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create a bulk contract template for a (cargo type, customs option) pair | `POST` | `/api/contract-templates` | `modules/contract-templates/contract-templates.controller.ts:56` |
|
||||||
|
| Add an article to the template | `POST` | `/api/contract-templates/:code/articles` | `modules/contract-templates/contract-templates.controller.ts:118` |
|
||||||
|
| Render an HTML preview of the template against mock contract data | `POST` | `/api/contract-templates/:code/preview` | `modules/contract-templates/contract-templates.controller.ts:97` |
|
||||||
|
| Replace the full ordered article list (used for reorder) | `PUT` | `/api/contract-templates/:code/articles` | `modules/contract-templates/contract-templates.controller.ts:111` |
|
||||||
|
| Update template metadata (name, title, recitals, active flag) | `PATCH` | `/api/contract-templates/:code` | `modules/contract-templates/contract-templates.controller.ts:77` |
|
||||||
|
| Update an article's title or body | `PATCH` | `/api/contract-templates/:code/articles/:articleId` | `modules/contract-templates/contract-templates.controller.ts:125` |
|
||||||
|
| Delete a staff-created bulk template (system templates refuse) | `DELETE` | `/api/contract-templates/:code` | `modules/contract-templates/contract-templates.controller.ts:84` |
|
||||||
|
| Remove an article from the template | `DELETE` | `/api/contract-templates/:code/articles/:articleId` | `modules/contract-templates/contract-templates.controller.ts:136` |
|
||||||
|
|
||||||
|
### Driver
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create a new driver | `POST` | `/api/drivers` | `modules/drivers/drivers.controller.ts:41` |
|
||||||
|
| Upload driver documents (code driver_docs) | `POST` | `/api/drivers/:id/documents` | `modules/drivers/drivers.controller.ts:80` |
|
||||||
|
| Update a driver | `PATCH` | `/api/drivers/:id` | `modules/drivers/drivers.controller.ts:128` |
|
||||||
|
| Delete a driver | `DELETE` | `/api/drivers/:id` | `modules/drivers/drivers.controller.ts:138` |
|
||||||
|
| Delete a driver document | `DELETE` | `/api/drivers/:id/documents/:fileId` | `modules/drivers/drivers.controller.ts:121` |
|
||||||
|
|
||||||
|
### Dropdown Setting
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create a new dropdown setting | `POST` | `/api/dropdown-settings` | `modules/dropdown-settings/dropdown-settings.controller.ts:61` |
|
||||||
|
| Append a single option to a setting | `POST` | `/api/dropdown-settings/:id/options` | `modules/dropdown-settings/dropdown-settings.controller.ts:98` |
|
||||||
|
| Replace the full option list for a setting | `PUT` | `/api/dropdown-settings/:id/options` | `modules/dropdown-settings/dropdown-settings.controller.ts:88` |
|
||||||
|
| Update a dropdown setting's metadata | `PATCH` | `/api/dropdown-settings/:id` | `modules/dropdown-settings/dropdown-settings.controller.ts:68` |
|
||||||
|
| Update a single option | `PATCH` | `/api/dropdown-settings/options/:optionId` | `modules/dropdown-settings/dropdown-settings.controller.ts:108` |
|
||||||
|
| Soft-delete a dropdown setting | `DELETE` | `/api/dropdown-settings/:id` | `modules/dropdown-settings/dropdown-settings.controller.ts:78` |
|
||||||
|
| Soft-delete a single option | `DELETE` | `/api/dropdown-settings/options/:optionId` | `modules/dropdown-settings/dropdown-settings.controller.ts:118` |
|
||||||
|
|
||||||
|
### EIMS Invoice
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Register the invoice with MoR EIMS. Idempotent — an invoice that already has an IRN is returned unchanged | `POST` | `/api/invoices/:id/eims/register` | `modules/eims/eims-invoice.controller.ts:32` |
|
||||||
|
| Resolve an unacknowledged submission: record the IRN confirmed with MoR, or discard it. Clears the system-wide block | `POST` | `/api/invoices/:id/eims/resolve` | `modules/eims/eims-invoice.controller.ts:49` |
|
||||||
|
| Verify the invoice's stored IRN against EIMS | `POST` | `/api/invoices/:id/eims/verify` | `modules/eims/eims-invoice.controller.ts:42` |
|
||||||
|
|
||||||
|
### Exchange Setting
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Set the USD→ETB fallback by hand (used only while CBE is unreachable) | `PATCH` | `/api/exchange-settings` | `modules/exchange-settings/exchange-settings.controller.ts:35` |
|
||||||
|
|
||||||
|
### Facility
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create a new facility | `POST` | `/api/facilities` | `modules/facilities/facilities.controller.ts:22` |
|
||||||
|
| Update a facility | `PATCH` | `/api/facilities/:id` | `modules/facilities/facilities.controller.ts:41` |
|
||||||
|
| Delete a facility (soft delete) | `DELETE` | `/api/facilities/:id` | `modules/facilities/facilities.controller.ts:48` |
|
||||||
|
|
||||||
|
### Fayda Verification
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Start a VeriFayda 2.0 verification session | `POST` | `/api/fayda/verification/start` | `modules/verifayda/verifayda.controller.ts:44` |
|
||||||
|
|
||||||
|
### File Upload Setting
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create a new file upload setting | `POST` | `/api/file-upload-settings` | `modules/file-upload-settings/file-upload-settings.controller.ts:56` |
|
||||||
|
| Append a single field to a setting | `POST` | `/api/file-upload-settings/:id/fields` | `modules/file-upload-settings/file-upload-settings.controller.ts:93` |
|
||||||
|
| Replace the full field list for a setting | `PUT` | `/api/file-upload-settings/:id/fields` | `modules/file-upload-settings/file-upload-settings.controller.ts:83` |
|
||||||
|
| Update a file upload setting's metadata | `PATCH` | `/api/file-upload-settings/:id` | `modules/file-upload-settings/file-upload-settings.controller.ts:63` |
|
||||||
|
| Update a single field | `PATCH` | `/api/file-upload-settings/fields/:fieldId` | `modules/file-upload-settings/file-upload-settings.controller.ts:103` |
|
||||||
|
| Soft-delete a file upload setting | `DELETE` | `/api/file-upload-settings/:id` | `modules/file-upload-settings/file-upload-settings.controller.ts:73` |
|
||||||
|
| Soft-delete a single field | `DELETE` | `/api/file-upload-settings/fields/:fieldId` | `modules/file-upload-settings/file-upload-settings.controller.ts:113` |
|
||||||
|
|
||||||
|
### First Mile
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create a first-mile leg | `POST` | `/api/first-mile` | `modules/first-mile/first-mile.controller.ts:84` |
|
||||||
|
| Set per-vehicle actual distances (does not generate an invoice) | `POST` | `/api/first-mile/:id/distances` | `modules/first-mile/first-mile.controller.ts:124` |
|
||||||
|
| Generate the first-mile delivery-fee invoice | `POST` | `/api/first-mile/:id/invoice` | `modules/first-mile/first-mile.controller.ts:100` |
|
||||||
|
| Set the vehicles assigned to a first-mile pickup (multi-truck) | `POST` | `/api/first-mile/:id/vehicles` | `modules/first-mile/first-mile.controller.ts:114` |
|
||||||
|
| Accept a paid booking and create a first-mile leg | `POST` | `/api/first-mile/accept/:reference` | `modules/first-mile/first-mile.controller.ts:77` |
|
||||||
|
| Update a first-mile leg | `PATCH` | `/api/first-mile/:id` | `modules/first-mile/first-mile.controller.ts:91` |
|
||||||
|
| Soft-delete a first-mile leg | `DELETE` | `/api/first-mile/:id` | `modules/first-mile/first-mile.controller.ts:134` |
|
||||||
|
|
||||||
|
### Fuel
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Record fuel purchase | `POST` | `/api/fuel/purchases` | `modules/fuel/fuel.controller.ts:22` |
|
||||||
|
|
||||||
|
### GPS Tracking
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Register a GPS tracker | `POST` | `/api/gps/devices` | `modules/gps-tracking/gps-tracking.controller.ts:52` |
|
||||||
|
| Update a GPS tracker (name / assigned vehicle) | `PATCH` | `/api/gps/devices/:id` | `modules/gps-tracking/gps-tracking.controller.ts:59` |
|
||||||
|
| Delete a GPS tracker | `DELETE` | `/api/gps/devices/:id` | `modules/gps-tracking/gps-tracking.controller.ts:66` |
|
||||||
|
|
||||||
|
### Import Operation
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Batch 12: record declaration serial number | `POST` | `/api/import-operations/customs/:bookingId/declaration` | `modules/import-operations/import-operations.controller.ts:53` |
|
||||||
|
| Batch 12: upload IM4/IM5/T1/permit/payment-slip documents | `POST` | `/api/import-operations/customs/:bookingId/documents` | `modules/import-operations/import-operations.controller.ts:44` |
|
||||||
|
| Batch 12: mark duties and taxes paid | `POST` | `/api/import-operations/customs/:bookingId/duties-taxes-paid` | `modules/import-operations/import-operations.controller.ts:71` |
|
||||||
|
| Batch 12: notify duties and taxes | `POST` | `/api/import-operations/customs/:bookingId/notify-duties-taxes` | `modules/import-operations/import-operations.controller.ts:62` |
|
||||||
|
| Batch 12: mark import release permitted | `POST` | `/api/import-operations/customs/:bookingId/release-permitted` | `modules/import-operations/import-operations.controller.ts:86` |
|
||||||
|
| Batch 12: assign customs risk | `POST` | `/api/import-operations/customs/:bookingId/risk` | `modules/import-operations/import-operations.controller.ts:80` |
|
||||||
|
| Batch 8: report a Djibouti import incident / exception | `POST` | `/api/import-operations/djibouti-incidents` | `modules/import-operations/import-operations.controller.ts:32` |
|
||||||
|
| Batch 16: create an empty container return record | `POST` | `/api/import-operations/empty-container-returns` | `modules/import-operations/import-operations.controller.ts:101` |
|
||||||
|
| Batch 16: advance empty container return workflow | `POST` | `/api/import-operations/empty-container-returns/:id/status` | `modules/import-operations/import-operations.controller.ts:107` |
|
||||||
|
|
||||||
|
### Incident
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Report an incident | `POST` | `/api/incidents` | `modules/incidents/incidents.controller.ts:36` |
|
||||||
|
| Update an incident | `PATCH` | `/api/incidents/:id` | `modules/incidents/incidents.controller.ts:72` |
|
||||||
|
| Delete an incident | `DELETE` | `/api/incidents/:id` | `modules/incidents/incidents.controller.ts:79` |
|
||||||
|
|
||||||
|
### Interchange Document
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Generate interchange document from a train schedule handover | `POST` | `/api/interchange-documents/generate-from-schedule` | `modules/interchange-documents/interchange-documents.controller.ts:41` |
|
||||||
|
| Acknowledge an interchange document | `PATCH` | `/api/interchange-documents/:id/acknowledge` | `modules/interchange-documents/interchange-documents.controller.ts:48` |
|
||||||
|
| Dispute an interchange document | `PATCH` | `/api/interchange-documents/:id/dispute` | `modules/interchange-documents/interchange-documents.controller.ts:58` |
|
||||||
|
|
||||||
|
### Last Mile
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create a last-mile leg | `POST` | `/api/last-mile` | `modules/last-mile/last-mile.controller.ts:97` |
|
||||||
|
| Set each truck\'s own detention window (arrived at destination / returned) | `POST` | `/api/last-mile/:id/detention-times` | `modules/last-mile/last-mile.controller.ts:142` |
|
||||||
|
| Set per-vehicle actual distances (does not generate an invoice) | `POST` | `/api/last-mile/:id/distances` | `modules/last-mile/last-mile.controller.ts:132` |
|
||||||
|
| Generate the delivery-fee invoice for a last-mile leg | `POST` | `/api/last-mile/:id/invoice` | `modules/last-mile/last-mile.controller.ts:179` |
|
||||||
|
| Record proof of delivery (signature + photos) and complete the leg | `POST` | `/api/last-mile/:id/proof-of-delivery` | `modules/last-mile/last-mile.controller.ts:166` |
|
||||||
|
| Set the vehicles assigned to a last-mile delivery (multi-truck) | `POST` | `/api/last-mile/:id/vehicles` | `modules/last-mile/last-mile.controller.ts:122` |
|
||||||
|
| Set each truck\'s warehouse gate arrival/departure times | `POST` | `/api/last-mile/:id/warehouse-gate-times` | `modules/last-mile/last-mile.controller.ts:154` |
|
||||||
|
| Accept a paid booking and create a last-mile leg | `POST` | `/api/last-mile/accept/:reference` | `modules/last-mile/last-mile.controller.ts:90` |
|
||||||
|
| Update a last-mile leg | `PATCH` | `/api/last-mile/:id` | `modules/last-mile/last-mile.controller.ts:104` |
|
||||||
|
| Soft-delete a last-mile leg | `DELETE` | `/api/last-mile/:id` | `modules/last-mile/last-mile.controller.ts:113` |
|
||||||
|
|
||||||
|
### Last Mile Request
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 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` | `/api/last-mile-requests/:id/approve` | `modules/last-mile-requests/last-mile-requests.controller.ts:119` |
|
||||||
|
| Customer agrees and signs the LM contract — then the advance invoice is issued | `POST` | `/api/last-mile-requests/:id/contract/sign` | `modules/last-mile-requests/last-mile-requests.controller.ts:83` |
|
||||||
|
| Truck & Machinery chief rejects the request with a reason | `POST` | `/api/last-mile-requests/:id/reject` | `modules/last-mile-requests/last-mile-requests.controller.ts:130` |
|
||||||
|
| Customer confirms which containers go via EDR last-mile | `POST` | `/api/last-mile-requests/:id/submit` | `modules/last-mile-requests/last-mile-requests.controller.ts:108` |
|
||||||
|
|
||||||
|
### Locomotive
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create a locomotive | `POST` | `/api/locomotives` | `modules/locomotives/locomotives.controller.ts:58` |
|
||||||
|
| Decommission a locomotive | `POST` | `/api/locomotives/:id/decommission` | `modules/locomotives/locomotives.controller.ts:72` |
|
||||||
|
| Update a locomotive | `PATCH` | `/api/locomotives/:id` | `modules/locomotives/locomotives.controller.ts:65` |
|
||||||
|
| Permanently delete a locomotive (irreversible; refused if any train references it) | `DELETE` | `/api/locomotives/:id/permanent` | `modules/locomotives/locomotives.controller.ts:82` |
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Record maintenance cost | `POST` | `/api/maintenance/costs` | `modules/maintenance/maintenance.controller.ts:38` |
|
||||||
|
| Define/adjust a service interval (e.g. oil change every 10,000 km) | `POST` | `/api/maintenance/intervals` | `modules/maintenance/maintenance.controller.ts:59` |
|
||||||
|
| Create part | `POST` | `/api/maintenance/parts` | `modules/maintenance/maintenance.controller.ts:150` |
|
||||||
|
| Schedule maintenance | `POST` | `/api/maintenance/schedules` | `modules/maintenance/maintenance.controller.ts:31` |
|
||||||
|
| Create warranty | `POST` | `/api/maintenance/warranties` | `modules/maintenance/maintenance.controller.ts:186` |
|
||||||
|
| Create work order | `POST` | `/api/maintenance/work-orders` | `modules/maintenance/maintenance.controller.ts:110` |
|
||||||
|
| Update part | `PATCH` | `/api/maintenance/parts/:id` | `modules/maintenance/maintenance.controller.ts:170` |
|
||||||
|
| Update maintenance schedule | `PATCH` | `/api/maintenance/schedules/:id` | `modules/maintenance/maintenance.controller.ts:45` |
|
||||||
|
| Update work order | `PATCH` | `/api/maintenance/work-orders/:id` | `modules/maintenance/maintenance.controller.ts:134` |
|
||||||
|
| Deactivate a service interval (stops auto-scheduling) | `DELETE` | `/api/maintenance/intervals/:id` | `modules/maintenance/maintenance.controller.ts:73` |
|
||||||
|
| Delete part | `DELETE` | `/api/maintenance/parts/:id` | `modules/maintenance/maintenance.controller.ts:177` |
|
||||||
|
| Delete warranty | `DELETE` | `/api/maintenance/warranties/:id` | `modules/maintenance/maintenance.controller.ts:200` |
|
||||||
|
| Delete work order | `DELETE` | `/api/maintenance/work-orders/:id` | `modules/maintenance/maintenance.controller.ts:141` |
|
||||||
|
|
||||||
|
### Notification Inbox
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Mark all my notifications as read | `POST` | `/api/notifications/read-all` | `modules/notification-inbox/notification-inbox.controller.ts:53` |
|
||||||
|
| Mark one of my notifications as read | `PATCH` | `/api/notifications/:id/read` | `modules/notification-inbox/notification-inbox.controller.ts:44` |
|
||||||
|
|
||||||
|
### Organization User
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create an organization user without assigning positions | `POST` | `/api/backoffice/organizations/:orgId/users` | `modules/backoffice/backoffice.controller.ts:24` |
|
||||||
|
| Replace org-scoped roles assigned to an employee user | `PUT` | `/api/backoffice/organizations/:orgId/employee-users/:userId/roles` | `modules/backoffice/backoffice.controller.ts:58` |
|
||||||
|
|
||||||
|
### OTP
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Send OTP | `POST` | `/api/otp/send` | `modules/otp/otp.controller.ts:41` |
|
||||||
|
| Verify OTP | `POST` | `/api/otp/verify` | `modules/otp/otp.controller.ts:62` |
|
||||||
|
|
||||||
|
### Password Reset
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Send a password-reset code to the account's email AND phone | `POST` | `/api/auth/forgot-password/request` | `modules/auth/forgot-password.controller.ts:30` |
|
||||||
|
| Validate a staff-issued reset link and return its set-password ticket | `POST` | `/api/auth/forgot-password/resolve-link` | `modules/auth/forgot-password.controller.ts:73` |
|
||||||
|
| Exchange a valid reset code for a single-use set-password ticket | `POST` | `/api/auth/forgot-password/verify` | `modules/auth/forgot-password.controller.ts:62` |
|
||||||
|
| Send a password-reset link to a customer's primary contact | `POST` | `/api/backoffice/customers/:companyId/reset-password` | `modules/auth/customer-reset.controller.ts:49` |
|
||||||
|
|
||||||
|
### Payment
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance | `POST` | `/api/billing/invoices/:id/confirm-offline` | `modules/billing/billing.controller.ts:81` |
|
||||||
|
| Confirm an OTP-debit payment (CAC Bank) for one of the customer's invoices | `POST` | `/api/billing/my-invoices/:id/confirm` | `modules/billing/portal-billing.controller.ts:102` |
|
||||||
|
| Initiate payment for one of the customer's invoices | `POST` | `/api/billing/my-invoices/:id/pay` | `modules/billing/portal-billing.controller.ts:86` |
|
||||||
|
| Live still-payable check + payer name for a CBE bill (called while CBE is on the line) | `POST` | `/api/internal/payments/bill-query` | `modules/payment/internal-payment.controller.ts:56` |
|
||||||
|
| Apply a payment.succeeded / payment.failed event from the payment service (idempotent) | `POST` | `/api/internal/payments/mark-paid` | `modules/payment/internal-payment.controller.ts:45` |
|
||||||
|
| Initiate payment for an invoice | `POST` | `/api/payments/initiate` | `modules/billing/payment.controller.ts:39` |
|
||||||
|
| Success-redirect ack: mark payment processing + invoice PAYMENT_PROCESSING (webhook remains source of truth) | `POST` | `/api/payments/redirect-success/:bookingId` | `modules/payment/payment.controller.ts:89` |
|
||||||
|
|
||||||
|
### Priority Config
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create a priority config | `POST` | `/api/priority-configs` | `modules/rule-engine/controllers/priority-configs.controller.ts:51` |
|
||||||
|
| Move a priority config up or down in display order | `POST` | `/api/priority-configs/:id/move-order` | `modules/rule-engine/controllers/priority-configs.controller.ts:66` |
|
||||||
|
| Bulk reorder priority configs by ID list | `POST` | `/api/priority-configs/reorder` | `modules/rule-engine/controllers/priority-configs.controller.ts:58` |
|
||||||
|
| Update a priority config | `PATCH` | `/api/priority-configs/:id` | `modules/rule-engine/controllers/priority-configs.controller.ts:74` |
|
||||||
|
| Soft-delete a priority config | `DELETE` | `/api/priority-configs/:id` | `modules/rule-engine/controllers/priority-configs.controller.ts:81` |
|
||||||
|
|
||||||
|
### Priority Rule Change Request
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Submit a priority-rule change for approval | `POST` | `/api/priority-rule-change-requests` | `modules/rule-engine/controllers/priority-rule-change-requests.controller.ts:34` |
|
||||||
|
| Approve and apply a pending change | `POST` | `/api/priority-rule-change-requests/:id/approve` | `modules/rule-engine/controllers/priority-rule-change-requests.controller.ts:52` |
|
||||||
|
| Reject a pending change | `POST` | `/api/priority-rule-change-requests/:id/reject` | `modules/rule-engine/controllers/priority-rule-change-requests.controller.ts:65` |
|
||||||
|
|
||||||
|
### Procurement
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create an asset acquisition | `POST` | `/api/procurement/acquisitions` | `modules/procurement/procurement.controller.ts:56` |
|
||||||
|
| Create an asset disposal | `POST` | `/api/procurement/disposals` | `modules/procurement/procurement.controller.ts:90` |
|
||||||
|
| Create a vendor | `POST` | `/api/procurement/vendors` | `modules/procurement/procurement.controller.ts:28` |
|
||||||
|
| Update an asset acquisition | `PATCH` | `/api/procurement/acquisitions/:id` | `modules/procurement/procurement.controller.ts:75` |
|
||||||
|
| Update a vendor | `PATCH` | `/api/procurement/vendors/:id` | `modules/procurement/procurement.controller.ts:41` |
|
||||||
|
| Delete an asset acquisition | `DELETE` | `/api/procurement/acquisitions/:id` | `modules/procurement/procurement.controller.ts:82` |
|
||||||
|
| Delete an asset disposal | `DELETE` | `/api/procurement/disposals/:id` | `modules/procurement/procurement.controller.ts:103` |
|
||||||
|
| Delete a vendor | `DELETE` | `/api/procurement/vendors/:id` | `modules/procurement/procurement.controller.ts:48` |
|
||||||
|
|
||||||
|
### Rate
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create a rate (DRAFT) | `POST` | `/api/rates` | `modules/rule-engine/controllers/rates.controller.ts:46` |
|
||||||
|
| CEO approves a rate | `POST` | `/api/rates/:id/approve` | `modules/rule-engine/controllers/rates.controller.ts:70` |
|
||||||
|
| Submit rate for CEO approval | `POST` | `/api/rates/:id/submit` | `modules/rule-engine/controllers/rates.controller.ts:63` |
|
||||||
|
| Update a DRAFT rate | `PATCH` | `/api/rates/:id` | `modules/rule-engine/controllers/rates.controller.ts:56` |
|
||||||
|
| Soft-delete a rate | `DELETE` | `/api/rates/:id` | `modules/rule-engine/controllers/rates.controller.ts:82` |
|
||||||
|
|
||||||
|
### Rate Change Request
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Propose a change to a LIVE rate | `POST` | `/api/rate-change-requests` | `modules/rule-engine/controllers/rate-change-requests.controller.ts:23` |
|
||||||
|
| Approve a rate change and put it into effect | `POST` | `/api/rate-change-requests/:id/approve` | `modules/rule-engine/controllers/rate-change-requests.controller.ts:37` |
|
||||||
|
| Reject a rate change — the rate keeps its current value | `POST` | `/api/rate-change-requests/:id/reject` | `modules/rule-engine/controllers/rate-change-requests.controller.ts:48` |
|
||||||
|
|
||||||
|
### Route
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create route | `POST` | `/api/routes` | `modules/routes/routes.controller.ts:61` |
|
||||||
|
| Update route | `PATCH` | `/api/routes/:id` | `modules/routes/routes.controller.ts:68` |
|
||||||
|
| Deactivate route | `DELETE` | `/api/routes/:id` | `modules/routes/routes.controller.ts:90` |
|
||||||
|
| Permanently delete a route (irreversible; refused while any train schedule references it) | `DELETE` | `/api/routes/:id/permanent` | `modules/routes/routes.controller.ts:79` |
|
||||||
|
|
||||||
|
### Schedule
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Reschedule train for maintenance (new departure + rebalance) | `POST` | `/api/train-scheduling/schedules/:id/maintenance` | `modules/scheduling-reschedule/scheduling-reschedule.controller.ts:52` |
|
||||||
|
| Execute a confirmed reschedule plan | `POST` | `/api/train-scheduling/schedules/:id/reschedule/execute` | `modules/scheduling-reschedule/scheduling-reschedule.controller.ts:30` |
|
||||||
|
| Preview reschedule / government preempt plan | `POST` | `/api/train-scheduling/schedules/:id/reschedule/preview` | `modules/scheduling-reschedule/scheduling-reschedule.controller.ts:20` |
|
||||||
|
|
||||||
|
### Service Type
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create a service type | `POST` | `/api/service-types` | `modules/rule-engine/controllers/service-types.controller.ts:51` |
|
||||||
|
| Move a service type up or down in display order | `POST` | `/api/service-types/:id/move-order` | `modules/rule-engine/controllers/service-types.controller.ts:36` |
|
||||||
|
| Bulk reorder service types by ID list | `POST` | `/api/service-types/reorder` | `modules/rule-engine/controllers/service-types.controller.ts:28` |
|
||||||
|
| Update a service type | `PATCH` | `/api/service-types/:id` | `modules/rule-engine/controllers/service-types.controller.ts:58` |
|
||||||
|
| Soft-delete a service type | `DELETE` | `/api/service-types/:id` | `modules/rule-engine/controllers/service-types.controller.ts:65` |
|
||||||
|
|
||||||
|
### Shipping Line
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create a shipping line | `POST` | `/api/shipping-lines` | `modules/rule-engine/controllers/shipping-lines.controller.ts:33` |
|
||||||
|
| Update a shipping line | `PATCH` | `/api/shipping-lines/:id` | `modules/rule-engine/controllers/shipping-lines.controller.ts:40` |
|
||||||
|
| Soft-delete a shipping line | `DELETE` | `/api/shipping-lines/:id` | `modules/rule-engine/controllers/shipping-lines.controller.ts:47` |
|
||||||
|
|
||||||
|
### Signature
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create or update the reusable saved signature | `PUT` | `/api/me/signature` | `modules/signatures/signatures.controller.ts:23` |
|
||||||
|
|
||||||
|
### Support Chat
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Start chatting with a company (returns the thread if one exists) | `POST` | `/api/support/agent/conversations` | `modules/support-chat/support-chat-agent.controller.ts:49` |
|
||||||
|
| Reply as an agent, optionally with attachments | `POST` | `/api/support/agent/conversations/:id/messages` | `modules/support-chat/support-chat-agent.controller.ts:74` |
|
||||||
|
| Mark a thread read (agent side) | `POST` | `/api/support/agent/conversations/:id/read` | `modules/support-chat/support-chat-agent.controller.ts:114` |
|
||||||
|
| Send a message as the customer (optionally with attachments), opening the thread if needed | `POST` | `/api/support/conversation/messages` | `modules/support-chat/support-chat.controller.ts:65` |
|
||||||
|
| Mark my company's thread read (customer side) | `POST` | `/api/support/conversation/read` | `modules/support-chat/support-chat.controller.ts:102` |
|
||||||
|
|
||||||
|
### Support Content
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Restore a version — re-saves it as a new version, never destructive | `POST` | `/api/support-content/documents/:slug/versions/:version/restore` | `modules/support-content/support-content.controller.ts:123` |
|
||||||
|
| Upload an image or video for a help section | `POST` | `/api/support-content/media` | `modules/support-content/support-content.controller.ts:55` |
|
||||||
|
| Replace a document's payload, recording a new version | `PATCH` | `/api/support-content/documents/:slug` | `modules/support-content/support-content.controller.ts:93` |
|
||||||
|
|
||||||
|
### Train
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Register a new train | `POST` | `/api/trains` | `modules/trains/trains.controller.ts:33` |
|
||||||
|
| Update a train | `PATCH` | `/api/trains/:id` | `modules/trains/trains.controller.ts:52` |
|
||||||
|
| Delete a train | `DELETE` | `/api/trains/:id` | `modules/trains/trains.controller.ts:59` |
|
||||||
|
|
||||||
|
### Train Build
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Build a train: code + yard + 2+ locomotives (+ optional wagons) | `POST` | `/api/train-builder` | `modules/trains/train-builder.controller.ts:50` |
|
||||||
|
| Reactivate a deactivated train back to AVAILABLE | `POST` | `/api/train-builder/:id/activate` | `modules/trains/train-builder.controller.ts:158` |
|
||||||
|
| Deactivate the train (park it) — only allowed with no active schedule | `POST` | `/api/train-builder/:id/deactivate` | `modules/trains/train-builder.controller.ts:149` |
|
||||||
|
| Persist a drag-reorder of the full consist | `POST` | `/api/train-builder/:id/reorder-wagons` | `modules/trains/train-builder.controller.ts:142` |
|
||||||
|
| Append AVAILABLE wagons from the train's yard to the consist | `POST` | `/api/train-builder/:id/wagons` | `modules/trains/train-builder.controller.ts:109` |
|
||||||
|
| Detach one wagon and move it to MAINTENANCE status | `POST` | `/api/train-builder/:id/wagons/:wagonId/maintenance` | `modules/trains/train-builder.controller.ts:131` |
|
||||||
|
| Replace the locomotive set (minimum 1, same yard) | `PUT` | `/api/train-builder/:id/locomotives` | `modules/trains/train-builder.controller.ts:78` |
|
||||||
|
| Edit the train's name and fixed import/export run numbers | `PATCH` | `/api/train-builder/:id/details` | `modules/trains/train-builder.controller.ts:88` |
|
||||||
|
| Relocate the train — its locomotives and wagons move to the new yard with it | `PATCH` | `/api/train-builder/:id/yard` | `modules/trains/train-builder.controller.ts:100` |
|
||||||
|
| Disband the train (release wagons and locomotives) | `DELETE` | `/api/train-builder/:id` | `modules/trains/train-builder.controller.ts:165` |
|
||||||
|
| Detach one wagon from the consist | `DELETE` | `/api/train-builder/:id/wagons/:wagonId` | `modules/trains/train-builder.controller.ts:120` |
|
||||||
|
|
||||||
|
### Train Schedule
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Staff: place a paid booking onto a fitting train (notifies customer on date change) | `POST` | `/api/train-scheduling/bookings/:bookingId/allocate` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:880` |
|
||||||
|
| Staff: expire a reservation and free its capacity | `POST` | `/api/train-scheduling/bookings/:bookingId/expire` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:845` |
|
||||||
|
| Staff: mark a reserved booking paid and allocate it now | `POST` | `/api/train-scheduling/bookings/:bookingId/mark-paid` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:835` |
|
||||||
|
| Re-point a booking to another OPEN same-route schedule | `POST` | `/api/train-scheduling/bookings/:bookingId/move-schedule` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:855` |
|
||||||
|
| Preview a bulk train schedule | `POST` | `/api/train-scheduling/bulk/preview` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:302` |
|
||||||
|
| Create a bulk train schedule | `POST` | `/api/train-scheduling/bulk/schedules` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:316` |
|
||||||
|
| Assign bulk bookings to a train schedule | `POST` | `/api/train-scheduling/bulk/schedules/:id/assign-bookings` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:349` |
|
||||||
|
| Cancel bulk train schedule | `POST` | `/api/train-scheduling/bulk/schedules/:id/cancel` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:976` |
|
||||||
|
| Preview a container train schedule | `POST` | `/api/train-scheduling/container/preview` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:295` |
|
||||||
|
| Create a container train schedule | `POST` | `/api/train-scheduling/container/schedules` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:309` |
|
||||||
|
| Assign container bookings to a train schedule | `POST` | `/api/train-scheduling/container/schedules/:id/assign-bookings` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:335` |
|
||||||
|
| Cancel container train schedule | `POST` | `/api/train-scheduling/container/schedules/:id/cancel` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:969` |
|
||||||
|
| Preview a mixed-capable train schedule | `POST` | `/api/train-scheduling/preview` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:288` |
|
||||||
|
| Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged) | `POST` | `/api/train-scheduling/schedules/:id/adjust-consist` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:197` |
|
||||||
|
| Mark a dispatched train arrived (move assets to destination yard, free assets) | `POST` | `/api/train-scheduling/schedules/:id/arrive` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:915` |
|
||||||
|
| Assign bookings to a train schedule (mixed-capable) | `POST` | `/api/train-scheduling/schedules/:id/assign-bookings` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:323` |
|
||||||
|
| Assign one linked unallocated booking to wagons (preserves existing assignments) | `POST` | `/api/train-scheduling/schedules/:id/assign-unassigned-booking` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:423` |
|
||||||
|
| Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard) | `POST` | `/api/train-scheduling/schedules/:id/bookings/:bookingId/load` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:564` |
|
||||||
|
| Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival | `POST` | `/api/train-scheduling/schedules/:id/bookings/:bookingId/unload` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:577` |
|
||||||
|
| Log the train passing a station (final station triggers arrival) | `POST` | `/api/train-scheduling/schedules/:id/checkpoints` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:903` |
|
||||||
|
| Confirm cargo loaded on the train (any direction; unblocks import-Djibouti dispatch) | `POST` | `/api/train-scheduling/schedules/:id/confirm-loading` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:662` |
|
||||||
|
| Dispatch a scheduled train | `POST` | `/api/train-scheduling/schedules/:id/dispatch` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:514` |
|
||||||
|
| Staff finished document review early — run the batch/payment phase now (applies to the whole route-day group) | `POST` | `/api/train-scheduling/schedules/:id/doc-review-complete` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:824` |
|
||||||
|
| Finalize a draft train schedule | `POST` | `/api/train-scheduling/schedules/:id/finalize` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:507` |
|
||||||
|
| Depart loaded import train from Djibouti | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/depart` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:675` |
|
||||||
|
| Upload/check an import Djibouti-side document | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/documents` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:622` |
|
||||||
|
| Mark import Djibouti gatepass permission granted | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/gatepass-granted` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:632` |
|
||||||
|
| Generate import load list / marshalling document summary | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/load-list` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:685` |
|
||||||
|
| Confirm import cargo loaded on train at Djibouti | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/loaded-on-train` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:652` |
|
||||||
|
| Mark import train ready for loading at Djibouti | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/ready-for-loading` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:642` |
|
||||||
|
| Confirm intercity cargo loaded (train must be at the booking's origin yard) | `POST` | `/api/train-scheduling/schedules/:id/intercity/:bookingId/load` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:590` |
|
||||||
|
| Confirm intercity cargo unloaded at the booking's destination yard (completes the booking) | `POST` | `/api/train-scheduling/schedules/:id/intercity/:bookingId/unload` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:602` |
|
||||||
|
| Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking) | `POST` | `/api/train-scheduling/schedules/:id/intercity/accept` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:541` |
|
||||||
|
| Maintenance reschedule: move the train to a new departure with every allocated booking aboard — links, wagons and window settings unchanged | `POST` | `/api/train-scheduling/schedules/:id/maintenance` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:798` |
|
||||||
|
| Pin physical wagons to train set slots | `POST` | `/api/train-scheduling/schedules/:id/pin-wagons` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:500` |
|
||||||
|
| Run wagon-level allocation for all eligible linked bookings | `POST` | `/api/train-scheduling/schedules/:id/run-allocation` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:747` |
|
||||||
|
| Manually run the batch fill for a schedule | `POST` | `/api/train-scheduling/schedules/:id/run-batch` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:739` |
|
||||||
|
| Switch out commercial bookings to allocate a government booking in their place | `POST` | `/api/train-scheduling/schedules/:id/switch-government-booking` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:439` |
|
||||||
|
| Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads) | `POST` | `/api/train-scheduling/schedules/:id/wagons/:wagonId/move-load` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:402` |
|
||||||
|
| Update global train scheduling rules (singleton) | `PATCH` | `/api/train-scheduling/global-rules` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:124` |
|
||||||
|
| Open or close a schedule booking window | `PATCH` | `/api/train-scheduling/schedules/:id/booking-window` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:756` |
|
||||||
|
| Update a container number on a wagon slot | `PATCH` | `/api/train-scheduling/schedules/:id/container-items/:itemId` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:391` |
|
||||||
|
| Mark import bookings loaded/unloaded on this schedule (tracking only, does not affect dispatch) | `PATCH` | `/api/train-scheduling/schedules/:id/import-loading-status` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:474` |
|
||||||
|
| Mark bookings loaded/unloaded on this schedule (any direction, pre-dispatch only) | `PATCH` | `/api/train-scheduling/schedules/:id/loading-status` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:487` |
|
||||||
|
| 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` | `/api/train-scheduling/schedules/:id/schedule-date` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:784` |
|
||||||
|
| Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens | `PATCH` | `/api/train-scheduling/schedules/:id/window-rule` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:770` |
|
||||||
|
| Unassign a booking from a train schedule | `DELETE` | `/api/train-scheduling/schedules/:id/bookings/:bookingId` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:363` |
|
||||||
|
| Remove an empty wagon slot from a train | `DELETE` | `/api/train-scheduling/schedules/:id/wagons/:trainSetWagonId` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:378` |
|
||||||
|
|
||||||
|
### Transit Agent
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create a transit agent | `POST` | `/api/transit-agents` | `modules/transit-agents/transit-agents.controller.ts:66` |
|
||||||
|
| Update a transit agent | `PATCH` | `/api/transit-agents/:id` | `modules/transit-agents/transit-agents.controller.ts:73` |
|
||||||
|
| Soft-delete a transit agent | `DELETE` | `/api/transit-agents/:id` | `modules/transit-agents/transit-agents.controller.ts:80` |
|
||||||
|
|
||||||
|
### Truck Type
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create a truck type | `POST` | `/api/truck-types` | `modules/truck-types/truck-types.controller.ts:58` |
|
||||||
|
| Update a truck type | `PATCH` | `/api/truck-types/:id` | `modules/truck-types/truck-types.controller.ts:65` |
|
||||||
|
| Soft-delete a truck type | `DELETE` | `/api/truck-types/:id` | `modules/truck-types/truck-types.controller.ts:72` |
|
||||||
|
|
||||||
|
### User Trade Access
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Set the trade directions a backoffice user may see | `PUT` | `/api/user-trade-access/:userId` | `modules/user-trade-access/user-trade-access.controller.ts:45` |
|
||||||
|
|
||||||
|
### Vehicle
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create a new vehicle | `POST` | `/api/vehicles` | `modules/vehicles/vehicles.controller.ts:37` |
|
||||||
|
| Update a vehicle | `PATCH` | `/api/vehicles/:id` | `modules/vehicles/vehicles.controller.ts:78` |
|
||||||
|
| Delete a vehicle | `DELETE` | `/api/vehicles/:id` | `modules/vehicles/vehicles.controller.ts:88` |
|
||||||
|
|
||||||
|
### Wagon
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create a new wagon | `POST` | `/api/wagons` | `modules/wagons/wagons.controller.ts:39` |
|
||||||
|
| Assign wagon to a train | `POST` | `/api/wagons/:id/assign-train` | `modules/wagons/wagons.controller.ts:100` |
|
||||||
|
| Unassign wagon from train | `POST` | `/api/wagons/:id/unassign-train` | `modules/wagons/wagons.controller.ts:107` |
|
||||||
|
| Set the status of multiple wagons (audited in wagon_status_logs) | `POST` | `/api/wagons/bulk-status` | `modules/wagons/wagons.controller.ts:121` |
|
||||||
|
| Transfer multiple wagons to a destination yard | `POST` | `/api/wagons/bulk-transfer` | `modules/wagons/wagons.controller.ts:114` |
|
||||||
|
| Update a wagon | `PATCH` | `/api/wagons/:id` | `modules/wagons/wagons.controller.ts:71` |
|
||||||
|
| Delete a wagon | `DELETE` | `/api/wagons/:id` | `modules/wagons/wagons.controller.ts:93` |
|
||||||
|
| Permanently delete a wagon (irreversible; refused if it has movements, containers or train-set slots) | `DELETE` | `/api/wagons/:id/permanent` | `modules/wagons/wagons.controller.ts:82` |
|
||||||
|
|
||||||
|
### Wagon Transfer Request
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| File a count-only wagon-transfer request | `POST` | `/api/wagon-transfer-requests` | `modules/wagons/wagon-transfer-requests.controller.ts:50` |
|
||||||
|
| Withdraw a request that has not moved any wagon yet (use close-short once wagons have moved) | `POST` | `/api/wagon-transfer-requests/:id/cancel` | `modules/wagons/wagon-transfer-requests.controller.ts:167` |
|
||||||
|
| OCC: end the request with fewer wagons than asked for — what moved stays, the requester is told the shortfall | `POST` | `/api/wagon-transfer-requests/:id/close-short` | `modules/wagons/wagon-transfer-requests.controller.ts:153` |
|
||||||
|
| OCC: pick wagons and execute the transfer | `POST` | `/api/wagon-transfer-requests/:id/fulfill` | `modules/wagons/wagon-transfer-requests.controller.ts:142` |
|
||||||
|
| OCC: accept-and-execute a subset of pending requests (auto-picks available wagons; the rest stay PENDING) | `POST` | `/api/wagon-transfer-requests/bulk-fulfill` | `modules/wagons/wagon-transfer-requests.controller.ts:73` |
|
||||||
|
|
||||||
|
### Wagon Type
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create a wagon type | `POST` | `/api/wagon-types` | `modules/wagon-types/wagon-types.controller.ts:53` |
|
||||||
|
| Update a wagon type | `PATCH` | `/api/wagon-types/:id` | `modules/wagon-types/wagon-types.controller.ts:60` |
|
||||||
|
| Soft-delete a wagon type | `DELETE` | `/api/wagon-types/:id` | `modules/wagon-types/wagon-types.controller.ts:67` |
|
||||||
|
|
||||||
|
### Warehouse
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create a warehouse allocation rule | `POST` | `/api/warehouse-allocation-rules` | `modules/warehouses/warehouse-rules.controller.ts:33` |
|
||||||
|
| Preview the yard/warehouse/zone a booking would be allocated to | `POST` | `/api/warehouse-allocation/preview` | `modules/warehouses/warehouse-rules.controller.ts:55` |
|
||||||
|
| Create a storage / demurrage fee rule | `POST` | `/api/warehouse-fee-rules` | `modules/warehouses/warehouse-rules.controller.ts:70` |
|
||||||
|
| Acknowledge / snooze an item fee-accrual alert | `POST` | `/api/warehouse-fees/accrual/:inventoryId/acknowledge` | `modules/warehouses/warehouse-rules.controller.ts:106` |
|
||||||
|
| Create warehouse | `POST` | `/api/warehouses` | `modules/warehouses/warehouses.controller.ts:51` |
|
||||||
|
| Create a yard within a warehouse | `POST` | `/api/warehouses/:warehouseId/yards` | `modules/warehouses/warehouses.controller.ts:78` |
|
||||||
|
| Update a warehouse allocation rule | `PATCH` | `/api/warehouse-allocation-rules/:id` | `modules/warehouses/warehouse-rules.controller.ts:40` |
|
||||||
|
| Update a fee rule | `PATCH` | `/api/warehouse-fee-rules/:id` | `modules/warehouses/warehouse-rules.controller.ts:77` |
|
||||||
|
| Update warehouse | `PATCH` | `/api/warehouses/:id` | `modules/warehouses/warehouses.controller.ts:64` |
|
||||||
|
| Delete a warehouse allocation rule | `DELETE` | `/api/warehouse-allocation-rules/:id` | `modules/warehouses/warehouse-rules.controller.ts:47` |
|
||||||
|
| Delete a fee rule | `DELETE` | `/api/warehouse-fee-rules/:id` | `modules/warehouses/warehouse-rules.controller.ts:84` |
|
||||||
|
| Remove an accrual acknowledgement (re-surface for alerts) | `DELETE` | `/api/warehouse-fees/accrual/:inventoryId/acknowledge` | `modules/warehouses/warehouse-rules.controller.ts:119` |
|
||||||
|
|
||||||
|
### Warehouse Fee Invoice
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Generate a truck-detention invoice for a last-mile leg (per truck per day) | `POST` | `/api/last-mile/:id/generate-truck-detention-invoice` | `modules/warehouses/warehouse-invoice.controller.ts:28` |
|
||||||
|
| Record a payment against a warehouse fee invoice | `POST` | `/api/warehouse-fee-invoices/:id/pay` | `modules/warehouses/warehouse-invoice.controller.ts:109` |
|
||||||
|
| Initiate Telebirr/Waafi payment for a warehouse fee invoice | `POST` | `/api/warehouse-fee-invoices/:id/pay-online` | `modules/warehouses/warehouse-invoice.controller.ts:116` |
|
||||||
|
| Generate a warehouse fee invoice from Batch 5 fee calculation | `POST` | `/api/warehouse-inventory/:id/generate-fee-invoice` | `modules/warehouses/warehouse-invoice.controller.ts:20` |
|
||||||
|
| Cancel a warehouse fee invoice | `PATCH` | `/api/warehouse-fee-invoices/:id/cancel` | `modules/warehouses/warehouse-invoice.controller.ts:102` |
|
||||||
|
|
||||||
|
### Warehouse Inspection Report
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Upload inspection images / documents | `POST` | `/api/warehouse-inspection-reports/:id/attachments` | `modules/warehouses/warehouse-inspection.controller.ts:68` |
|
||||||
|
| Create an inspection / damage report for an inventory item | `POST` | `/api/warehouse-inventory/:inventoryId/inspection-reports` | `modules/warehouses/warehouse-inspection.controller.ts:37` |
|
||||||
|
| Update an inspection report | `PATCH` | `/api/warehouse-inspection-reports/:id` | `modules/warehouses/warehouse-inspection.controller.ts:61` |
|
||||||
|
|
||||||
|
### Warehouse Inventory
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Deliver import goods to the customer + capture proof of delivery | `POST` | `/api/warehouse-inventory/:id/deliver` | `modules/warehouses/warehouse-inventory.controller.ts:594` |
|
||||||
|
| Final terminal release / gate clearance (blocked while fees unpaid) | `POST` | `/api/warehouse-inventory/:id/gate-clearance` | `modules/warehouses/warehouse-inventory.controller.ts:219` |
|
||||||
|
| Load READY_FOR_LOADING inventory onto a wagon | `POST` | `/api/warehouse-inventory/:id/load` | `modules/warehouses/warehouse-inventory.controller.ts:384` |
|
||||||
|
| Move inventory to another warehouse/yard/zone | `POST` | `/api/warehouse-inventory/:id/move` | `modules/warehouses/warehouse-inventory.controller.ts:359` |
|
||||||
|
| Mark reserved inventory READY_FOR_LOADING | `POST` | `/api/warehouse-inventory/:id/ready-for-loading` | `modules/warehouses/warehouse-inventory.controller.ts:373` |
|
||||||
|
| Mark inspected IMPORT inventory READY_FOR_PICKUP | `POST` | `/api/warehouse-inventory/:id/ready-for-pickup` | `modules/warehouses/warehouse-inventory.controller.ts:391` |
|
||||||
|
| Issue a DO / release order for ready-for-pickup inventory | `POST` | `/api/warehouse-inventory/:id/release` | `modules/warehouses/warehouse-inventory.controller.ts:402` |
|
||||||
|
| Mark received inventory as STORED (optional explicit warehouse/yard/zone) | `POST` | `/api/warehouse-inventory/:id/store` | `modules/warehouses/warehouse-inventory.controller.ts:366` |
|
||||||
|
| Auto-load READY_FOR_LOADING inventory with PAID bookings | `POST` | `/api/warehouse-inventory/auto-load-ready` | `modules/warehouses/warehouse-inventory.controller.ts:125` |
|
||||||
|
| Bulk auto-unload all arrived bookings into the warehouse | `POST` | `/api/warehouse-inventory/auto-unload-arrived` | `modules/warehouses/warehouse-inventory.controller.ts:118` |
|
||||||
|
| Approve delivery — customer records their full name (signature optional) | `POST` | `/api/warehouse-inventory/bookings/:bookingId/approve-delivery` | `modules/warehouses/warehouse-inventory.controller.ts:470` |
|
||||||
|
| Ask the customer to sign the handover (creates one if none, then notifies) | `POST` | `/api/warehouse-inventory/bookings/:bookingId/request-handover-signature` | `modules/warehouses/warehouse-inventory.controller.ts:509` |
|
||||||
|
| Unload a single arrived booking into a location | `POST` | `/api/warehouse-inventory/bookings/:bookingId/unload` | `modules/warehouses/warehouse-inventory.controller.ts:209` |
|
||||||
|
| Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED) | `POST` | `/api/warehouse-inventory/bulk-dispatch-export` | `modules/warehouses/warehouse-inventory.controller.ts:195` |
|
||||||
|
| Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING) | `POST` | `/api/warehouse-inventory/bulk-mark-inspected` | `modules/warehouses/warehouse-inventory.controller.ts:202` |
|
||||||
|
| Unload all eligible export items assigned to an arrived Djibouti-side train | `POST` | `/api/warehouse-inventory/export/auto-unload-at-djibouti` | `modules/warehouses/warehouse-inventory.controller.ts:294` |
|
||||||
|
| Customer signs one handover (EDR last-mile: one signature per truck) | `POST` | `/api/warehouse-inventory/handovers/:handoverId/sign` | `modules/warehouses/warehouse-inventory.controller.ts:493` |
|
||||||
|
| Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED) | `POST` | `/api/warehouse-inventory/import/auto-unload-arrived-bookings` | `modules/warehouses/warehouse-inventory.controller.ts:244` |
|
||||||
|
| Receive inventory at a warehouse location | `POST` | `/api/warehouse-inventory/receive` | `modules/warehouses/warehouse-inventory.controller.ts:322` |
|
||||||
|
| Bulk-receive selected eligible PAID bookings into a location | `POST` | `/api/warehouse-inventory/receive-bulk` | `modules/warehouses/warehouse-inventory.controller.ts:140` |
|
||||||
|
| Reserve stored inventory for a PAID booking | `POST` | `/api/warehouse-inventory/reserve` | `modules/warehouses/warehouse-inventory.controller.ts:330` |
|
||||||
|
| Load selected inventory items onto their allocated wagons for a train | `POST` | `/api/warehouse-inventory/train/:scheduleId/load` | `modules/warehouses/warehouse-inventory.controller.ts:184` |
|
||||||
|
| Mark loaded inventory DISPATCHED (left the terminal) | `PATCH` | `/api/warehouse-inventory/:id/dispatch` | `modules/warehouses/warehouse-inventory.controller.ts:602` |
|
||||||
|
| Record Yes/No double handling after unloading (Yes applies the double-handling fee rule) | `PATCH` | `/api/warehouse-inventory/bookings/:bookingId/double-handling` | `modules/warehouses/warehouse-inventory.controller.ts:556` |
|
||||||
|
|
||||||
|
### Warehouse Yard
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create a zone within a yard | `POST` | `/api/warehouse-yards/:yardId/zones` | `modules/warehouses/warehouse-yards.controller.ts:50` |
|
||||||
|
| Update warehouse yard | `PATCH` | `/api/warehouse-yards/:id` | `modules/warehouses/warehouse-yards.controller.ts:36` |
|
||||||
|
|
||||||
|
### Warehouse Zone
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Update warehouse zone | `PATCH` | `/api/warehouse-zones/:id` | `modules/warehouses/warehouse-zones.controller.ts:37` |
|
||||||
|
|
||||||
|
### Weight Limit Rule
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create a weight limit rule | `POST` | `/api/weight-limit-rules` | `modules/rule-engine/controllers/weight-limit-rules.controller.ts:32` |
|
||||||
|
| Update a weight limit rule | `PATCH` | `/api/weight-limit-rules/:id` | `modules/rule-engine/controllers/weight-limit-rules.controller.ts:39` |
|
||||||
|
| Soft-delete a weight limit rule | `DELETE` | `/api/weight-limit-rules/:id` | `modules/rule-engine/controllers/weight-limit-rules.controller.ts:46` |
|
||||||
|
|
||||||
|
### Yard
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create a yard | `POST` | `/api/yards` | `modules/rule-engine/controllers/yards.controller.ts:53` |
|
||||||
|
| Move a yard up or down in display order | `POST` | `/api/yards/:id/move-order` | `modules/rule-engine/controllers/yards.controller.ts:38` |
|
||||||
|
| Bulk reorder yards by ID list | `POST` | `/api/yards/reorder` | `modules/rule-engine/controllers/yards.controller.ts:30` |
|
||||||
|
| Update a yard | `PATCH` | `/api/yards/:id` | `modules/rule-engine/controllers/yards.controller.ts:60` |
|
||||||
|
| Soft-delete a yard | `DELETE` | `/api/yards/:id` | `modules/rule-engine/controllers/yards.controller.ts:67` |
|
||||||
|
|
||||||
|
### Yard Distance
|
||||||
|
|
||||||
|
| Title | Method | Endpoint | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Create a yard distance | `POST` | `/api/yard-distances` | `modules/rule-engine/controllers/yard-distances.controller.ts:42` |
|
||||||
|
| Update a yard distance | `PATCH` | `/api/yard-distances/:id` | `modules/rule-engine/controllers/yard-distances.controller.ts:49` |
|
||||||
|
| Soft-delete a yard distance | `DELETE` | `/api/yard-distances/:id` | `modules/rule-engine/controllers/yard-distances.controller.ts:56` |
|
||||||
|
|
||||||
@@ -60,7 +60,6 @@
|
|||||||
"@nestjs/typeorm": "^11.0.1",
|
"@nestjs/typeorm": "^11.0.1",
|
||||||
"@nestjs/websockets": "^11.1.27",
|
"@nestjs/websockets": "^11.1.27",
|
||||||
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.6.0.tgz",
|
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.6.0.tgz",
|
||||||
"@tria-plc/auditlog": "file:../../local-packages/tria-plc-auditlog-1.1.2.tgz",
|
|
||||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz",
|
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz",
|
||||||
"amqp-connection-manager": "^5.0.0",
|
"amqp-connection-manager": "^5.0.0",
|
||||||
"amqplib": "^2.0.1",
|
"amqplib": "^2.0.1",
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
|
import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
|
||||||
import { IamModule } from "@tria-plc/iamapi-common";
|
import { IamModule } from "@tria-plc/iamapi-common";
|
||||||
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
|
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
|
||||||
import { MezgebModule } from "@tria-plc/auditlog";
|
|
||||||
|
|
||||||
import appConfig from "./config/app.config";
|
import appConfig from "./config/app.config";
|
||||||
import databaseConfig from "./config/database.config";
|
import databaseConfig from "./config/database.config";
|
||||||
@@ -113,7 +112,6 @@ import { LastMileRequestsModule } from "./modules/last-mile-requests/last-mile-r
|
|||||||
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
|
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
|
||||||
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
|
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
|
||||||
import { AiModule } from "./modules/ai/ai.module";
|
import { AiModule } from "./modules/ai/ai.module";
|
||||||
import { AuditModule } from "./modules/audit/audit.module";
|
|
||||||
import { RequestLogMiddleware } from "@edr/api-common";
|
import { RequestLogMiddleware } from "@edr/api-common";
|
||||||
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
|
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
|
||||||
import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache";
|
import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache";
|
||||||
@@ -170,19 +168,6 @@ if (!process.env.APPLICATION_NAME) {
|
|||||||
return dataSource;
|
return dataSource;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
// Request + entity-level audit logging over RabbitMQ (@tria-plc/auditlog).
|
|
||||||
// Must come after TypeOrmModule above so it picks up this app's DataSource.
|
|
||||||
// rmqUrl falls back the same way notifications.module.ts's RABBITMQ_URL
|
|
||||||
// does: the dev broker only provisions the `edr` user on the `payment`
|
|
||||||
// vhost (docker-compose's RABBITMQ_DEFAULT_USER/VHOST), so an unset
|
|
||||||
// RABBITMQ_URL must land there too, not on guest@'/' (403 ACCESS_REFUSED).
|
|
||||||
MezgebModule.forRoot({
|
|
||||||
applicationName: "freight-api",
|
|
||||||
rmqUrl:
|
|
||||||
process.env.RABBITMQ_URL ??
|
|
||||||
process.env.PAYMENT_RABBITMQ_URL ??
|
|
||||||
"amqp://localhost:5672",
|
|
||||||
}),
|
|
||||||
SharedAuthModule,
|
SharedAuthModule,
|
||||||
IamModule.forRoot({
|
IamModule.forRoot({
|
||||||
applications: [EDR_FREIGHT_APPLICATION],
|
applications: [EDR_FREIGHT_APPLICATION],
|
||||||
@@ -259,7 +244,6 @@ if (!process.env.APPLICATION_NAME) {
|
|||||||
EimsModule,
|
EimsModule,
|
||||||
FleetHistoryModule,
|
FleetHistoryModule,
|
||||||
AiModule,
|
AiModule,
|
||||||
AuditModule,
|
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
EdrOrgSeeder,
|
EdrOrgSeeder,
|
||||||
|
|||||||
@@ -90,6 +90,10 @@ export const TrainSchedulingReschedule = () =>
|
|||||||
export const TrainSchedulingRulesManage = () =>
|
export const TrainSchedulingRulesManage = () =>
|
||||||
BookingStaff(FREIGHT_PERMS.trainScheduling.rulesManage);
|
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,
|
* Fleet guards take an optional granular per-resource key (locomotives:create,
|
||||||
* wagons:delete, …). The legacy coarse fleet:view / fleet:manage keys remain
|
* wagons:delete, …). The legacy coarse fleet:view / fleet:manage keys remain
|
||||||
|
|||||||
@@ -56,11 +56,6 @@ import { EmployeePositionActivePeriod } from "@tria-plc/iamapi-common/entities/i
|
|||||||
import { UnitConfiguration } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-configuration.entity";
|
import { UnitConfiguration } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-configuration.entity";
|
||||||
import { Site } from "@tria-plc/iamapi-common/entities/iam/site/site.entity";
|
import { Site } from "@tria-plc/iamapi-common/entities/iam/site/site.entity";
|
||||||
import { SiteSetting } from "@tria-plc/iamapi-common/entities/iam/site/site-setting.entity";
|
import { SiteSetting } from "@tria-plc/iamapi-common/entities/iam/site/site-setting.entity";
|
||||||
import { AuditLog, AuditLogCommand } from "@tria-plc/auditlog";
|
|
||||||
|
|
||||||
// @tria-plc/auditlog's entities live in node_modules, same as the iam ones —
|
|
||||||
// the glob below only matches this app's own src/**/*.entity.ts.
|
|
||||||
const auditEntities = [AuditLog, AuditLogCommand];
|
|
||||||
|
|
||||||
const iamEntities = [
|
const iamEntities = [
|
||||||
UnitSetting,
|
UnitSetting,
|
||||||
@@ -185,7 +180,6 @@ export function buildDataSourceOptions(): DataSourceOptions {
|
|||||||
entities: [
|
entities: [
|
||||||
__dirname + "/../**/*.entity.{ts,js}",
|
__dirname + "/../**/*.entity.{ts,js}",
|
||||||
...iamEntities,
|
...iamEntities,
|
||||||
...auditEntities,
|
|
||||||
],
|
],
|
||||||
migrations: [],
|
migrations: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
ResponseTransformInterceptor,
|
ResponseTransformInterceptor,
|
||||||
createValidationPipe,
|
createValidationPipe,
|
||||||
} from "@edr/api-common";
|
} from "@edr/api-common";
|
||||||
import { getAuditLoggerConfig } from "@tria-plc/auditlog";
|
|
||||||
|
|
||||||
import { AppModule } from "./app.module";
|
import { AppModule } from "./app.module";
|
||||||
|
|
||||||
@@ -170,11 +169,6 @@ export async function createFreightApp(): Promise<NestExpressApplication> {
|
|||||||
app.useGlobalFilters(new HttpExceptionFilter());
|
app.useGlobalFilters(new HttpExceptionFilter());
|
||||||
app.useGlobalInterceptors(new ResponseTransformInterceptor());
|
app.useGlobalInterceptors(new ResponseTransformInterceptor());
|
||||||
|
|
||||||
// Audit listener: consumes the RMQ events MezgebModule's client interceptor
|
|
||||||
// (app.module.ts) emits and persists them via the AuditLogController /
|
|
||||||
// AuditLogCommandController @EventPattern handlers. Same queue config the
|
|
||||||
// client side uses, reused from the package so the two never drift apart.
|
|
||||||
app.connectMicroservice(getAuditLoggerConfig());
|
|
||||||
await app.startAllMicroservices();
|
await app.startAllMicroservices();
|
||||||
|
|
||||||
const config = new DocumentBuilder()
|
const config = new DocumentBuilder()
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
84
apps/edr-freight-api/src/modules/audit/audit-actor.ts
Normal file
84
apps/edr-freight-api/src/modules/audit/audit-actor.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
140
apps/edr-freight-api/src/modules/audit/audit-endpoint-matcher.ts
Normal file
140
apps/edr-freight-api/src/modules/audit/audit-endpoint-matcher.ts
Normal 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();
|
||||||
641
apps/edr-freight-api/src/modules/audit/audit-endpoints.ts
Normal file
641
apps/edr-freight-api/src/modules/audit/audit-endpoints.ts
Normal 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 1–2 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"],
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,32 +1,46 @@
|
|||||||
import { Controller, Get, Query } from "@nestjs/common";
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { PaginatedResponse } from '@edr/types';
|
||||||
|
|
||||||
import { BookingStaff } from "../../common/booking-guards";
|
import { BookingStaff } from '../../common/booking-guards';
|
||||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { AuditService } from "./audit.service";
|
import { AuditService } from './audit.service';
|
||||||
|
import { AuditLog } from './entities/audit-log.entity';
|
||||||
|
import { AuditLogQueryDto } from './dto/audit-log-query.dto';
|
||||||
|
|
||||||
@ApiTags("audit")
|
/**
|
||||||
@Controller("audit")
|
* Read-only view over the audit trail.
|
||||||
@BookingStaff(FREIGHT_PERMS.audit.view)
|
*
|
||||||
|
* 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 {
|
export class AuditController {
|
||||||
constructor(private readonly auditService: AuditService) {}
|
constructor(private readonly auditService: AuditService) {}
|
||||||
|
|
||||||
@Get("logs")
|
@Get('logs')
|
||||||
@ApiOperation({ summary: "List freight-api audit log commands" })
|
@BookingStaff(FREIGHT_PERMS.auditLog.view)
|
||||||
@ApiQuery({ name: "skip", type: Number, required: false })
|
@ApiOperation({
|
||||||
@ApiQuery({ name: "take", type: Number, required: false })
|
summary:
|
||||||
list(@Query("skip") skip?: string, @Query("take") take?: string) {
|
'List backoffice audit logs — filter by entity type, user, method, outcome and date range',
|
||||||
// Same fallback chain @tria-plc/auditlog's client interceptor uses to
|
})
|
||||||
// stamp AuditLog.application (mezgeb/client/client-audit.interceptor.js)
|
list(@Query() query: AuditLogQueryDto): Promise<PaginatedResponse<AuditLog>> {
|
||||||
// — reading it here instead of a hardcoded literal means this can't
|
return this.auditService.search(query);
|
||||||
// silently drift out of sync with whatever APPLICATION_NAME/APP_NAME
|
}
|
||||||
// actually is at runtime.
|
|
||||||
const application =
|
@Get('types')
|
||||||
process.env.APPLICATION_NAME ?? process.env.APP_NAME ?? "DEFAULT";
|
@BookingStaff(FREIGHT_PERMS.auditLog.view)
|
||||||
return this.auditService.list(
|
@ApiOperation({
|
||||||
application,
|
summary: 'Distinct entity types present in the audit log (filter dropdown)',
|
||||||
skip !== undefined ? parseInt(skip, 10) : undefined,
|
})
|
||||||
take !== undefined ? parseInt(take, 10) : undefined,
|
types(): Promise<string[]> {
|
||||||
);
|
return this.auditService.listTypes();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
189
apps/edr-freight-api/src/modules/audit/audit.interceptor.ts
Normal file
189
apps/edr-freight-api/src/modules/audit/audit.interceptor.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,13 +1,34 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
import { AuditLogCommand } from "@tria-plc/auditlog";
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { AuditController } from "./audit.controller";
|
import { AuditController } from './audit.controller';
|
||||||
import { AuditService } from "./audit.service";
|
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({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([AuditLogCommand])],
|
imports: [TypeOrmModule.forFeature([AuditLog])],
|
||||||
controllers: [AuditController],
|
controllers: [AuditController],
|
||||||
providers: [AuditService],
|
providers: [
|
||||||
|
AuditLogRepository,
|
||||||
|
AuditService,
|
||||||
|
{ provide: APP_INTERCEPTOR, useClass: AuditInterceptor },
|
||||||
|
],
|
||||||
|
exports: [AuditService, AuditLogRepository],
|
||||||
})
|
})
|
||||||
export class AuditModule {}
|
export class AuditModule {}
|
||||||
|
|||||||
195
apps/edr-freight-api/src/modules/audit/audit.sanitizer.ts
Normal file
195
apps/edr-freight-api/src/modules/audit/audit.sanitizer.ts
Normal 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}`;
|
||||||
|
}
|
||||||
@@ -1,70 +1,71 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { PaginatedResponse } from '@edr/types';
|
||||||
import { Repository } from "typeorm";
|
|
||||||
import { AuditLogCommand } from "@tria-plc/auditlog";
|
|
||||||
|
|
||||||
import { CLIENT_APP_HEADER } from "../auth/login-audience.middleware";
|
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';
|
||||||
|
|
||||||
export interface AuditLogListResult {
|
|
||||||
count: number;
|
|
||||||
items: AuditLogCommand[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Own read path onto @tria-plc/auditlog's tables, gated by AuditController's
|
|
||||||
* @BookingStaff — the package's own AuditLogCommandController (mounted at
|
|
||||||
* /api/audit-log-commands) ships with no guards at all, so it can't be used
|
|
||||||
* directly for a permission-gated UI. Query mirrors the package's
|
|
||||||
* AuditLogCommandService.buildAuditLogQuery/getAllAuditLogs exactly.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuditService {
|
export class AuditService {
|
||||||
constructor(
|
private readonly logger = new Logger(AuditService.name);
|
||||||
@InjectRepository(AuditLogCommand)
|
|
||||||
private readonly auditLogCommandRepository: Repository<AuditLogCommand>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async list(
|
constructor(private readonly auditLogRepository: AuditLogRepository) {}
|
||||||
application: string,
|
|
||||||
skip = 0,
|
|
||||||
take = 10,
|
|
||||||
): Promise<AuditLogListResult> {
|
|
||||||
const [items, count] = await this.auditLogCommandRepository
|
|
||||||
.createQueryBuilder("audit_log_commands")
|
|
||||||
.leftJoinAndSelect("audit_log_commands.auditLog", "auditLog")
|
|
||||||
.andWhere(
|
|
||||||
"(audit_log_commands.auditLogId IS NULL OR auditLog.application = :application)",
|
|
||||||
{ application },
|
|
||||||
)
|
|
||||||
.andWhere(
|
|
||||||
"(audit_log_commands.auditLogId IS NULL OR auditLog.status = :status)",
|
|
||||||
{ status: "Commit" },
|
|
||||||
)
|
|
||||||
// Backoffice-only view: portal (customer-facing) writes carry the same
|
|
||||||
// request-header set by every axios call from that app — see
|
|
||||||
// login-audience.middleware.ts. Rows with no linked auditLog (child/
|
|
||||||
// event commands with no request context) stay visible; they aren't
|
|
||||||
// attributable to any frontend, so they're not portal noise either.
|
|
||||||
.andWhere(
|
|
||||||
"(audit_log_commands.auditLogId IS NULL OR auditLog.requestHeader ->> :clientAppHeader = :clientApp)",
|
|
||||||
{ clientAppHeader: CLIENT_APP_HEADER, clientApp: "backoffice" },
|
|
||||||
)
|
|
||||||
.select([
|
|
||||||
"audit_log_commands.id",
|
|
||||||
"audit_log_commands.createdAt",
|
|
||||||
"audit_log_commands.deletedAt",
|
|
||||||
"audit_log_commands.entityName",
|
|
||||||
"audit_log_commands.queryMethod",
|
|
||||||
"audit_log_commands.changes",
|
|
||||||
"audit_log_commands.payload",
|
|
||||||
"auditLog.id",
|
|
||||||
"auditLog.user",
|
|
||||||
])
|
|
||||||
.addOrderBy("audit_log_commands.createdAt", "DESC")
|
|
||||||
.skip(skip)
|
|
||||||
.take(take)
|
|
||||||
.getManyAndCount();
|
|
||||||
|
|
||||||
return { count, items };
|
/**
|
||||||
|
* 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1267,18 +1267,18 @@ export class BookingsController {
|
|||||||
|
|
||||||
@Post(':id/clearance/delivery-order')
|
@Post(':id/clearance/delivery-order')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
async uploadBookingDeliveryOrder(
|
async uploadBookingDeliveryOrder(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
@Body('vesselArrivalDate') vesselArrivalDate: string | undefined,
|
@Body('vesselArrivalDate') vesselArrivalDate: string | undefined,
|
||||||
@Body('doCollectedDate') doCollectedDate: string | undefined,
|
@Body('doCollectedDate') doCollectedDate: string | undefined,
|
||||||
@CurrentUser() user: TCurrentUser,
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
|
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
|
||||||
id,
|
id,
|
||||||
file,
|
files ?? [],
|
||||||
resolveAuthUserId(user),
|
resolveAuthUserId(user),
|
||||||
{ vesselArrivalDate, doCollectedDate },
|
{ vesselArrivalDate, doCollectedDate },
|
||||||
);
|
);
|
||||||
@@ -1287,17 +1287,17 @@ export class BookingsController {
|
|||||||
|
|
||||||
@Post(':id/clearance/release-order')
|
@Post(':id/clearance/release-order')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
async uploadBookingReleaseOrder(
|
async uploadBookingReleaseOrder(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
@Body('vesselDepartureDate') vesselDepartureDate: string,
|
@Body('vesselDepartureDate') vesselDepartureDate: string,
|
||||||
@CurrentUser() user: TCurrentUser,
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
const result = await this.bookingClearanceService.uploadReleaseOrder(
|
const result = await this.bookingClearanceService.uploadReleaseOrder(
|
||||||
id,
|
id,
|
||||||
file,
|
files ?? [],
|
||||||
vesselDepartureDate,
|
vesselDepartureDate,
|
||||||
resolveAuthUserId(user),
|
resolveAuthUserId(user),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -59,9 +59,11 @@ import { PdfRenderService } from '../billing/documents/pdf-render.service';
|
|||||||
import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util';
|
import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The allocated train as the backoffice booking detail page needs it: which
|
* The train as the backoffice booking detail page needs it: which train, its
|
||||||
* train, its window phase, and both the planned and actual clock. Attached by
|
* window phase, and both the planned and actual clock. Attached by `findById`
|
||||||
* `findById` only when the booking is on a schedule.
|
* 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 {
|
export interface TrainScheduleSummary {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -74,6 +76,12 @@ export interface TrainScheduleSummary {
|
|||||||
actualArrivalAt: string | null;
|
actualArrivalAt: string | null;
|
||||||
windowPhase: string | null;
|
windowPhase: string | null;
|
||||||
paymentPhaseEndsAt: 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). */
|
/** 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
|
// Surface the assigned train's operational status so the portal stepper
|
||||||
// can show the Arrival stage: the booking status stays IN_TRANSIT from
|
// can show the Arrival stage: the booking status stays IN_TRANSIT from
|
||||||
// dispatch until delivery, so arrival is only knowable from the schedule.
|
// 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
|
const schedule = await this.dataSource
|
||||||
.getRepository(TrainSchedule)
|
.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 =
|
(booking as Booking & { trainScheduleStatus?: string | null }).trainScheduleStatus =
|
||||||
schedule?.status ?? null;
|
booking.trainScheduleId ? (schedule?.status ?? null) : null;
|
||||||
// Backoffice staff view: the allocated train's identity and clock, so the
|
// Backoffice staff view: the train's identity and clock, so the detail
|
||||||
// detail page can state which train the booking rides and when it runs
|
// page can state which train the booking rides and when it runs without a
|
||||||
// without a second round-trip to the schedules API.
|
// second round-trip to the schedules API.
|
||||||
(
|
(
|
||||||
booking as Booking & { trainScheduleSummary?: TrainScheduleSummary | null }
|
booking as Booking & { trainScheduleSummary?: TrainScheduleSummary | null }
|
||||||
).trainScheduleSummary = schedule
|
).trainScheduleSummary = schedule
|
||||||
@@ -2155,6 +2171,7 @@ export class BookingsService {
|
|||||||
actualArrivalAt: schedule.actualArrivalAt?.toISOString() ?? null,
|
actualArrivalAt: schedule.actualArrivalAt?.toISOString() ?? null,
|
||||||
windowPhase: schedule.windowPhase ?? null,
|
windowPhase: schedule.windowPhase ?? null,
|
||||||
paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null,
|
paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null,
|
||||||
|
isRequested: !booking.trainScheduleId,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ describe('BookingClearanceService', () => {
|
|||||||
const { service, bookingsRepository } = makeService({ booking: generalExportBooking });
|
const { service, bookingsRepository } = makeService({ booking: generalExportBooking });
|
||||||
const result = await service.uploadReleaseOrder(
|
const result = await service.uploadReleaseOrder(
|
||||||
'b-export',
|
'b-export',
|
||||||
{ fieldname: 'ro' } as Express.Multer.File,
|
[{ fieldname: 'ro' } as Express.Multer.File],
|
||||||
dateStr,
|
dateStr,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ContractDocPhase,
|
ContractDocPhase,
|
||||||
|
isDeliveryOrderFileCode,
|
||||||
isDraftDeclarationFileCode,
|
isDraftDeclarationFileCode,
|
||||||
type ClearanceFinalInvoiceSummary,
|
type ClearanceFinalInvoiceSummary,
|
||||||
type ClearanceOffloadState,
|
type ClearanceOffloadState,
|
||||||
@@ -29,7 +30,7 @@ import { GlOperationsService } from './gl-operations.service';
|
|||||||
import { GlExchangeService } from './gl-exchange.service';
|
import { GlExchangeService } from './gl-exchange.service';
|
||||||
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
|
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
|
||||||
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
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';
|
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.
|
// GL Djibouti may have uploaded the DO early (un-gated) — count it now.
|
||||||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
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.completeMilestoneForBooking(bookingId, 'DO_COLLECTED');
|
||||||
await this.workflowService.markReadyForOperation(bookingId);
|
await this.workflowService.markReadyForOperation(bookingId);
|
||||||
}
|
}
|
||||||
@@ -823,7 +824,7 @@ export class BookingClearanceService {
|
|||||||
|
|
||||||
async uploadDeliveryOrder(
|
async uploadDeliveryOrder(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
file: Express.Multer.File,
|
files: Express.Multer.File[],
|
||||||
userId?: string,
|
userId?: string,
|
||||||
dates?: { vesselArrivalDate?: string; doCollectedDate?: string },
|
dates?: { vesselArrivalDate?: string; doCollectedDate?: string },
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
@@ -832,19 +833,12 @@ export class BookingClearanceService {
|
|||||||
throw new BadRequestException('Delivery Order applies only to import bookings.');
|
throw new BadRequestException('Delivery Order applies only to import bookings.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!file) throw new BadRequestException('No Delivery Order uploaded');
|
|
||||||
|
|
||||||
const { vesselArrivalDate, doCollectedDate } = assertDoCollectionDates(dates);
|
const { vesselArrivalDate, doCollectedDate } = assertDoCollectionDates(dates);
|
||||||
|
|
||||||
// DO upload is deliberately un-gated: GL Djibouti may attach it at any point,
|
// 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
|
// any file type. The DO_COLLECTED milestone (and operation readiness) still
|
||||||
// waits for GL Ethiopia to finalize pre-clearance so the workflow order holds.
|
// waits for GL Ethiopia to finalize pre-clearance so the workflow order holds.
|
||||||
await this.filesService.upsertByCode({
|
await persistDeliveryOrderUploads(this.filesService, bookingId, 'bookings', files ?? []);
|
||||||
resourceId: bookingId,
|
|
||||||
resource: 'bookings',
|
|
||||||
code: 'delivery_order',
|
|
||||||
file,
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.bookingsRepository.update(bookingId, {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
vesselArrivalDate,
|
vesselArrivalDate,
|
||||||
@@ -880,7 +874,7 @@ export class BookingClearanceService {
|
|||||||
|
|
||||||
async uploadReleaseOrder(
|
async uploadReleaseOrder(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
file: Express.Multer.File,
|
files: Express.Multer.File[],
|
||||||
vesselDepartureDate: string,
|
vesselDepartureDate: string,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
): Promise<{ booking: Booking; hold: boolean; holdReason?: string }> {
|
): Promise<{ booking: Booking; hold: boolean; holdReason?: string }> {
|
||||||
@@ -894,7 +888,6 @@ export class BookingClearanceService {
|
|||||||
'RELEASE_ORDER_SECURED',
|
'RELEASE_ORDER_SECURED',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!file) throw new BadRequestException('No Release Order uploaded');
|
|
||||||
if (!vesselDepartureDate?.trim()) {
|
if (!vesselDepartureDate?.trim()) {
|
||||||
throw new BadRequestException('Vessel departure date is required');
|
throw new BadRequestException('Vessel departure date is required');
|
||||||
}
|
}
|
||||||
@@ -902,12 +895,7 @@ export class BookingClearanceService {
|
|||||||
const minDays = await this.resolveRoMinDays();
|
const minDays = await this.resolveRoMinDays();
|
||||||
const leadDays = this.daysUntil(vesselDepartureDate);
|
const leadDays = this.daysUntil(vesselDepartureDate);
|
||||||
|
|
||||||
await this.filesService.upsertByCode({
|
await persistReleaseOrderUploads(this.filesService, bookingId, 'bookings', files ?? []);
|
||||||
resourceId: bookingId,
|
|
||||||
resource: 'bookings',
|
|
||||||
code: 'release_order',
|
|
||||||
file,
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.bookingsRepository.update(bookingId, {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
vesselDepartureDate,
|
vesselDepartureDate,
|
||||||
|
|||||||
@@ -1974,6 +1974,11 @@ export class ContractBookingService {
|
|||||||
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
|
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
|
||||||
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
||||||
isGovernment: contract.isGovernment,
|
isGovernment: contract.isGovernment,
|
||||||
|
// The clearance fee is gated on this flag in BookingPricingService, and
|
||||||
|
// createUnderContract copies it off the contract. Omitting it here priced
|
||||||
|
// the preview WITHOUT the customs line the created booking is then billed
|
||||||
|
// — the customer confirmed one total and got invoiced a larger one.
|
||||||
|
customsClearingEnabled: contract.customsClearingEnabled,
|
||||||
shippingLineId: null,
|
shippingLineId: null,
|
||||||
contractRouteId: route?.id ?? null,
|
contractRouteId: route?.id ?? null,
|
||||||
originYardId: route?.originYardId ?? null,
|
originYardId: route?.originYardId ?? null,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ContractDocPhase,
|
ContractDocPhase,
|
||||||
|
isDeliveryOrderFileCode,
|
||||||
type ClearanceFinalInvoiceSummary,
|
type ClearanceFinalInvoiceSummary,
|
||||||
type ClearanceOffloadState,
|
type ClearanceOffloadState,
|
||||||
type ClearanceSecondDuty,
|
type ClearanceSecondDuty,
|
||||||
@@ -36,7 +37,7 @@ import { Contract } from './entities/contract.entity';
|
|||||||
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
|
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
|
||||||
import { FilterContractDto } from './dto/filter-contract.dto';
|
import { FilterContractDto } from './dto/filter-contract.dto';
|
||||||
import { AdviseContractDutyDto } from './dto/phased-clearance.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';
|
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.
|
// GL Djibouti may have uploaded the DO early (un-gated) — count it now.
|
||||||
const files = await this.filesService.findByResource(contractId, 'contracts');
|
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.completeMilestone(contractId, 'DO_COLLECTED');
|
||||||
await this.workflowService.markReadyForBooking(contractId);
|
await this.workflowService.markReadyForBooking(contractId);
|
||||||
}
|
}
|
||||||
@@ -1460,7 +1461,7 @@ export class ContractClearanceService {
|
|||||||
|
|
||||||
async uploadDeliveryOrder(
|
async uploadDeliveryOrder(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
file: Express.Multer.File,
|
files: Express.Multer.File[],
|
||||||
userId?: string,
|
userId?: string,
|
||||||
dates?: { vesselArrivalDate?: string; doCollectedDate?: string },
|
dates?: { vesselArrivalDate?: string; doCollectedDate?: string },
|
||||||
): Promise<Contract> {
|
): Promise<Contract> {
|
||||||
@@ -1470,19 +1471,12 @@ export class ContractClearanceService {
|
|||||||
throw new BadRequestException('Delivery Order applies only to import contracts.');
|
throw new BadRequestException('Delivery Order applies only to import contracts.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!file) throw new BadRequestException('No Delivery Order uploaded');
|
|
||||||
|
|
||||||
const { vesselArrivalDate, doCollectedDate } = assertDoCollectionDates(dates);
|
const { vesselArrivalDate, doCollectedDate } = assertDoCollectionDates(dates);
|
||||||
|
|
||||||
// DO upload is deliberately un-gated: GL Djibouti may attach it at any point,
|
// 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
|
// any file type. The DO_COLLECTED milestone (and booking readiness) still waits
|
||||||
// for GL Ethiopia to finalize pre-clearance so the workflow order holds.
|
// for GL Ethiopia to finalize pre-clearance so the workflow order holds.
|
||||||
await this.filesService.upsertByCode({
|
await persistDeliveryOrderUploads(this.filesService, contractId, 'contracts', files ?? []);
|
||||||
resourceId: contractId,
|
|
||||||
resource: 'contracts',
|
|
||||||
code: 'delivery_order',
|
|
||||||
file,
|
|
||||||
});
|
|
||||||
|
|
||||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
if (cycle) {
|
if (cycle) {
|
||||||
@@ -1520,7 +1514,7 @@ export class ContractClearanceService {
|
|||||||
|
|
||||||
async uploadReleaseOrder(
|
async uploadReleaseOrder(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
file: Express.Multer.File,
|
files: Express.Multer.File[],
|
||||||
vesselDepartureDate: string,
|
vesselDepartureDate: string,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
): Promise<{ contract: Contract; hold: boolean; holdReason?: string }> {
|
): Promise<{ contract: Contract; hold: boolean; holdReason?: string }> {
|
||||||
@@ -1535,7 +1529,6 @@ export class ContractClearanceService {
|
|||||||
'RELEASE_ORDER_SECURED',
|
'RELEASE_ORDER_SECURED',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!file) throw new BadRequestException('No Release Order uploaded');
|
|
||||||
if (!vesselDepartureDate?.trim()) {
|
if (!vesselDepartureDate?.trim()) {
|
||||||
throw new BadRequestException('Vessel departure date is required');
|
throw new BadRequestException('Vessel departure date is required');
|
||||||
}
|
}
|
||||||
@@ -1545,12 +1538,7 @@ export class ContractClearanceService {
|
|||||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
if (!cycle) throw new BadRequestException('No clearance cycle found');
|
if (!cycle) throw new BadRequestException('No clearance cycle found');
|
||||||
|
|
||||||
await this.filesService.upsertByCode({
|
await persistReleaseOrderUploads(this.filesService, contractId, 'contracts', files ?? []);
|
||||||
resourceId: contractId,
|
|
||||||
resource: 'contracts',
|
|
||||||
code: 'release_order',
|
|
||||||
file,
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.contractsRepository.updateCycle(cycle.id, {
|
await this.contractsRepository.updateCycle(cycle.id, {
|
||||||
vesselDepartureDate,
|
vesselDepartureDate,
|
||||||
|
|||||||
@@ -956,20 +956,20 @@ export class ContractsController {
|
|||||||
|
|
||||||
@Post(':id/clearance/delivery-order')
|
@Post(':id/clearance/delivery-order')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
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(
|
uploadDeliveryOrder(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
@Body('vesselArrivalDate') vesselArrivalDate: string | undefined,
|
@Body('vesselArrivalDate') vesselArrivalDate: string | undefined,
|
||||||
@Body('doCollectedDate') doCollectedDate: string | undefined,
|
@Body('doCollectedDate') doCollectedDate: string | undefined,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: AuthUserPayload,
|
||||||
) {
|
) {
|
||||||
return this.clearanceService.uploadDeliveryOrder(id, file, resolveAuthUserId(user), {
|
return this.clearanceService.uploadDeliveryOrder(id, files ?? [], resolveAuthUserId(user), {
|
||||||
vesselArrivalDate,
|
vesselArrivalDate,
|
||||||
doCollectedDate,
|
doCollectedDate,
|
||||||
});
|
});
|
||||||
@@ -977,18 +977,18 @@ export class ContractsController {
|
|||||||
|
|
||||||
@Post(':id/clearance/release-order')
|
@Post(':id/clearance/release-order')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
@ApiConsumes('multipart/form-data')
|
@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(
|
uploadReleaseOrder(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
@Body('vesselDepartureDate') vesselDepartureDate: string,
|
@Body('vesselDepartureDate') vesselDepartureDate: string,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: AuthUserPayload,
|
||||||
) {
|
) {
|
||||||
return this.clearanceService.uploadReleaseOrder(
|
return this.clearanceService.uploadReleaseOrder(
|
||||||
id,
|
id,
|
||||||
file,
|
files ?? [],
|
||||||
vesselDepartureDate,
|
vesselDepartureDate,
|
||||||
resolveAuthUserId(user),
|
resolveAuthUserId(user),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import {
|
|||||||
draftDeclarationFileLabel,
|
draftDeclarationFileLabel,
|
||||||
isDeclarationFileCode,
|
isDeclarationFileCode,
|
||||||
isDraftDeclarationFileCode,
|
isDraftDeclarationFileCode,
|
||||||
|
isDeliveryOrderFileCode,
|
||||||
|
isReleaseOrderFileCode,
|
||||||
|
deliveryOrderFileLabel,
|
||||||
|
releaseOrderFileLabel,
|
||||||
isImportTransitPermitFileCode,
|
isImportTransitPermitFileCode,
|
||||||
isExportTransportFileCode,
|
isExportTransportFileCode,
|
||||||
isT1TransportFileCode,
|
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. */
|
/** Require at least one export transport document in the upload batch. */
|
||||||
export function assertExportTransportFiles(files: Express.Multer.File[]): void {
|
export function assertExportTransportFiles(files: Express.Multer.File[]): void {
|
||||||
if (files.length === 0) {
|
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
|
const extraT1 = files
|
||||||
.filter((f) => f.code && isT1TransportFileCode(f.code) && !included.has(f.code))
|
.filter((f) => f.code && isT1TransportFileCode(f.code) && !included.has(f.code))
|
||||||
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
|
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
|
||||||
@@ -440,6 +552,22 @@ export function buildWorkflowFiles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (tradeDirection === 'EXPORT') {
|
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
|
const extraExportTransport = files
|
||||||
.filter((f) => f.code && isExportTransportFileCode(f.code) && !included.has(f.code))
|
.filter((f) => f.code && isExportTransportFileCode(f.code) && !included.has(f.code))
|
||||||
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
|
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { ContractBookingService } from './contract-booking.service';
|
||||||
|
import type { Contract } from './entities/contract.entity';
|
||||||
|
import type { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
import type { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The price the customer confirms in the modal comes from validateShipment,
|
||||||
|
* which prices an UNSAVED twin of the booking createUnderContract will write.
|
||||||
|
* Any contract field that reaches the pricing service must be copied onto that
|
||||||
|
* twin — a field left off doesn't fail loudly, it silently drops whole charge
|
||||||
|
* lines from the quote while the created booking is still billed for them.
|
||||||
|
*
|
||||||
|
* The regression this locks: `customsClearingEnabled` was missing, so
|
||||||
|
* BookingPricingService's `if (booking.customsClearingEnabled)` gate never
|
||||||
|
* opened in the preview. Container bookings quoted rail freight alone, then
|
||||||
|
* invoiced rail + customs clearance.
|
||||||
|
*/
|
||||||
|
describe('shipment preview / created booking parity', () => {
|
||||||
|
const contract = (over: Partial<Contract> = {}): Contract =>
|
||||||
|
({
|
||||||
|
id: 'c1',
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
tradeDirection: 'EXPORT',
|
||||||
|
paymentCurrency: 'ETB',
|
||||||
|
serviceTypeId: 'svc1',
|
||||||
|
customsClearingEnabled: true,
|
||||||
|
equipmentReturn: 'NO_RETURN',
|
||||||
|
isHazardous: false,
|
||||||
|
isReefer: false,
|
||||||
|
isGovernment: false,
|
||||||
|
cargoScope: [],
|
||||||
|
firstMilePickupAddress: null,
|
||||||
|
lastMileDeliveryAddress: null,
|
||||||
|
...over,
|
||||||
|
}) as Contract;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run validateShipment against stubbed collaborators and hand back the
|
||||||
|
* booking the pricing service was actually asked to price.
|
||||||
|
*/
|
||||||
|
const previewBookingFor = async (c: Contract): Promise<Booking> => {
|
||||||
|
let priced: Booking | null = null;
|
||||||
|
|
||||||
|
const svc = {
|
||||||
|
contractsRepository: { findByIdWithRelations: async () => c },
|
||||||
|
bookingPricingService: {
|
||||||
|
computePriceForBooking: async (b: Booking) => {
|
||||||
|
priced = b;
|
||||||
|
return {
|
||||||
|
lineItems: [],
|
||||||
|
totalAmount: 0,
|
||||||
|
currency: 'ETB',
|
||||||
|
overweightLines: [],
|
||||||
|
hardBlocked: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ruleEngineService: { capacityViolations: async () => [] },
|
||||||
|
resolveRoute: async () => null,
|
||||||
|
resolveShipmentCurrency: () => 'ETB',
|
||||||
|
resolveCargoTypeId: () => null,
|
||||||
|
resolveShipmentHandlingFlag: () => false,
|
||||||
|
resolveShipmentEquipmentReturn: () => c.equipmentReturn,
|
||||||
|
resolveBulkTons: () => 0,
|
||||||
|
resolveBulkWeightTons: () => 0,
|
||||||
|
resolveContainerTypeForSize: async () => ({ id: 'ct40', sizeFt: 40 }),
|
||||||
|
handlingCounts: () => ({
|
||||||
|
hazardousQuantity: 0,
|
||||||
|
reeferQuantity: 0,
|
||||||
|
returnQuantity: 0,
|
||||||
|
}),
|
||||||
|
max20ftPairDiffTons: async () => 2,
|
||||||
|
findContainerClashesOnTrain: async () => [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const dto = {
|
||||||
|
containers: [
|
||||||
|
{
|
||||||
|
containerSize: '40ft',
|
||||||
|
quantity: 2,
|
||||||
|
units: [{ vgmTons: 10 }, { vgmTons: 10 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as CreateBookingUnderContractDto;
|
||||||
|
|
||||||
|
await (
|
||||||
|
ContractBookingService.prototype as unknown as {
|
||||||
|
validateShipment: (
|
||||||
|
this: unknown,
|
||||||
|
id: string,
|
||||||
|
dto: CreateBookingUnderContractDto,
|
||||||
|
) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
).validateShipment.call(svc, 'c1', dto);
|
||||||
|
|
||||||
|
if (!priced) throw new Error('pricing service was never called');
|
||||||
|
return priced;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('prices the preview with customs clearing on when the contract clears', async () => {
|
||||||
|
// Without this the clearance fee is quoted as 0 and billed in full later.
|
||||||
|
const booking = await previewBookingFor(contract());
|
||||||
|
expect(booking.customsClearingEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves customs clearing off when the contract does not clear', async () => {
|
||||||
|
const booking = await previewBookingFor(
|
||||||
|
contract({ customsClearingEnabled: false }),
|
||||||
|
);
|
||||||
|
expect(booking.customsClearingEnabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries the contract mile legs so trucking is quoted too', async () => {
|
||||||
|
const booking = await previewBookingFor(
|
||||||
|
contract({
|
||||||
|
firstMilePickupAddress: 'Modjo Dry Port',
|
||||||
|
lastMileDeliveryAddress: 'Djibouti Port',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(booking.firstMilePickupAddress).toBe('Modjo Dry Port');
|
||||||
|
expect(booking.lastMileDeliveryAddress).toBe('Djibouti Port');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, Logger, SetMetadata } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { Nack, RabbitSubscribe } from "@golevelup/nestjs-rabbitmq";
|
import { Nack, RabbitSubscribe } from "@golevelup/nestjs-rabbitmq";
|
||||||
import { Public } from "@edr/api-common";
|
import { Public } from "@edr/api-common";
|
||||||
import {
|
import {
|
||||||
@@ -14,11 +14,6 @@ import { PaymentService as PaymentSvc } from "./payment.service";
|
|||||||
|
|
||||||
const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentService.FREIGHT];
|
const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentService.FREIGHT];
|
||||||
|
|
||||||
// @tria-plc/auditlog's global ClientLoggerInterceptor (present in deployed builds)
|
|
||||||
// crashes on non-HTTP contexts (`originalUrl.split` on a RabbitMQ message) and the
|
|
||||||
// resulting requeue storm blocks payment.succeeded forever. Its IgnoreLoggerAudit
|
|
||||||
// decorator is just this metadata key — set it directly so we don't need the package.
|
|
||||||
@SetMetadata("ignoreAuditLogger", true)
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PaymentEventsConsumer {
|
export class PaymentEventsConsumer {
|
||||||
private readonly logger = new Logger(PaymentEventsConsumer.name);
|
private readonly logger = new Logger(PaymentEventsConsumer.name);
|
||||||
|
|||||||
@@ -91,9 +91,8 @@ export class CargoTypesRepository implements ICargoTypesRepository {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Diffs the wagon-type links through the relation query builder rather than
|
* Diffs the wagon-type links through the relation query builder rather than
|
||||||
* an entity save: junction-row inserts from save() broadcast afterInsert with
|
* an entity save, so junction rows are written without broadcasting
|
||||||
* no entity attached, which the @tria-plc/auditlog subscriber (deployed
|
* afterInsert events for entity-less inserts.
|
||||||
* builds) dereferences and crashes the request on.
|
|
||||||
*/
|
*/
|
||||||
private async syncWagonTypes(
|
private async syncWagonTypes(
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
@@ -64,6 +64,14 @@ export class TrainSchedule extends BaseEntity {
|
|||||||
@Column({ name: 'train_number', type: 'varchar', length: 20, nullable: true })
|
@Column({ name: 'train_number', type: 'varchar', length: 20, nullable: true })
|
||||||
trainNumber?: string | null;
|
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
|
// Human-facing unique schedule reference (S-YYYY-NNNNN). Shown on the schedule
|
||||||
// list, booking windows, and load lists. Assigned at creation from the highest
|
// list, booking windows, and load lists. Assigned at creation from the highest
|
||||||
// sequence issued this year (see TrainSchedulesRepository.maxReferenceSequence).
|
// sequence issued this year (see TrainSchedulesRepository.maxReferenceSequence).
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
PortalCustomer,
|
PortalCustomer,
|
||||||
TrainSchedulingCancel,
|
TrainSchedulingCancel,
|
||||||
TrainSchedulingCreate,
|
TrainSchedulingCreate,
|
||||||
|
TrainSchedulingEditTrainNumber,
|
||||||
TrainSchedulingReschedule,
|
TrainSchedulingReschedule,
|
||||||
TrainSchedulingRulesManage,
|
TrainSchedulingRulesManage,
|
||||||
TrainSchedulingUpdate,
|
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 { UpdateTrainSchedulingGlobalRulesDto } from "../dto/update-train-scheduling-global-rules.dto";
|
||||||
import { UpdateScheduleWindowRuleDto } from "../dto/update-schedule-window-rule.dto";
|
import { UpdateScheduleWindowRuleDto } from "../dto/update-schedule-window-rule.dto";
|
||||||
import { UpdateScheduleDateDto } from "../dto/update-schedule-date.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 { MaintenanceRescheduleDto } from "../dto/maintenance-reschedule.dto";
|
||||||
import { TrainSchedulingService } from "../services/train-scheduling.service";
|
import { TrainSchedulingService } from "../services/train-scheduling.service";
|
||||||
import { BookingBatchService } from "../booking-batch.service";
|
import { BookingBatchService } from "../booking-batch.service";
|
||||||
@@ -795,6 +798,47 @@ export class TrainSchedulingController {
|
|||||||
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
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")
|
@Post("schedules/:id/maintenance")
|
||||||
@TrainSchedulingReschedule()
|
@TrainSchedulingReschedule()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1646,4 +1646,92 @@ describe('TrainSchedulingService', () => {
|
|||||||
).toBe(5);
|
).toBe(5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('maintenanceReschedule — window reopens when it had already finished', () => {
|
||||||
|
const { TrainSchedule } = jest.requireActual(
|
||||||
|
'../../train-schedules/entities/train-schedule.entity',
|
||||||
|
);
|
||||||
|
|
||||||
|
const doneExportSchedule = (extra: Record<string, unknown> = {}) => ({
|
||||||
|
id: 'sch-done',
|
||||||
|
status: 'SCHEDULED',
|
||||||
|
direction: 'EXPORT',
|
||||||
|
windowPhase: 'DONE',
|
||||||
|
bookingWindowStatus: 'CLOSED',
|
||||||
|
scheduledDepartureDate: new Date('2027-06-20T05:00:00.000Z'),
|
||||||
|
scheduledArrivalDate: null,
|
||||||
|
originStationId: 'yard-origin',
|
||||||
|
destinationStationId: 'yard-destination',
|
||||||
|
scheduleBookings: [],
|
||||||
|
// Frozen rule snapshot: desk 8–17 EAT, 24h lead, close 120min before departure.
|
||||||
|
ruleWindowOpenHour: 8,
|
||||||
|
ruleWindowCloseHour: 17,
|
||||||
|
ruleExportBookingLeadHours: 24,
|
||||||
|
ruleExportCloseOffsetMinutes: 120,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
let scheduleUpdate: jest.Mock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
scheduleUpdate = jest.fn().mockResolvedValue({ affected: 1 });
|
||||||
|
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||||
|
if (entity === TrainSchedulingGlobalRules) {
|
||||||
|
return { find: jest.fn().mockResolvedValue([]) };
|
||||||
|
}
|
||||||
|
if (entity === TrainSchedule) {
|
||||||
|
return { update: scheduleUpdate, find: jest.fn().mockResolvedValue([]) };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
|
update: jest.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
trainSchedulesRepository.findById.mockResolvedValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reopens a DONE export window against the new departure', async () => {
|
||||||
|
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(
|
||||||
|
doneExportSchedule(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// New departure 12:00 EAT → window opens 24h earlier (12:00 EAT, inside
|
||||||
|
// the desk) and closes at departure − 120min = 10:00 EAT.
|
||||||
|
await service.maintenanceReschedule('sch-done', {
|
||||||
|
newDepartureDate: '2027-06-20T09:00:00.000Z',
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(scheduleUpdate).toHaveBeenCalledWith(
|
||||||
|
'sch-done',
|
||||||
|
expect.objectContaining({
|
||||||
|
scheduledDepartureDate: new Date('2027-06-20T09:00:00.000Z'),
|
||||||
|
windowPhase: 'PRE_WINDOW',
|
||||||
|
bookingWindowStatus: 'CLOSED',
|
||||||
|
windowOpensAt: new Date('2027-06-19T09:00:00.000Z'),
|
||||||
|
windowClosesAt: new Date('2027-06-20T07:00:00.000Z'),
|
||||||
|
docReviewCompletedAt: null,
|
||||||
|
docReviewEndsAt: null,
|
||||||
|
paymentPhaseEndsAt: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a FULL train closed — nothing left to sell', async () => {
|
||||||
|
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(
|
||||||
|
doneExportSchedule({ bookingWindowStatus: 'FULL' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.maintenanceReschedule('sch-done', {
|
||||||
|
newDepartureDate: '2027-06-20T09:00:00.000Z',
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const written = scheduleUpdate.mock.calls[0][1];
|
||||||
|
expect(written.scheduledDepartureDate).toEqual(
|
||||||
|
new Date('2027-06-20T09:00:00.000Z'),
|
||||||
|
);
|
||||||
|
expect(written.windowPhase).toBeUndefined();
|
||||||
|
expect(written.windowOpensAt).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ import {
|
|||||||
} from '../dto/import-djibouti-operation.dto';
|
} from '../dto/import-djibouti-operation.dto';
|
||||||
import { UpdateScheduleWindowRuleDto } from '../dto/update-schedule-window-rule.dto';
|
import { UpdateScheduleWindowRuleDto } from '../dto/update-schedule-window-rule.dto';
|
||||||
import { UpdateScheduleDateDto } from '../dto/update-schedule-date.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 { MaintenanceRescheduleDto } from '../dto/maintenance-reschedule.dto';
|
||||||
import { type BookingWindowConfig } from '../booking-window.config';
|
import { type BookingWindowConfig } from '../booking-window.config';
|
||||||
import { BookingWindowGateway } from '../booking-window.gateway';
|
import { BookingWindowGateway } from '../booking-window.gateway';
|
||||||
@@ -132,14 +134,17 @@ import {
|
|||||||
} from '../utils/wagon-plan.util';
|
} from '../utils/wagon-plan.util';
|
||||||
import { CorridorBudget } from '../corridor-capacity.util';
|
import { CorridorBudget } from '../corridor-capacity.util';
|
||||||
import { deriveScheduleDirection } from '../utils/derive-schedule-direction.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 { pickLowestFreeNumber, pickTrainNumberPool } from '../train-number.util';
|
||||||
import {
|
import {
|
||||||
bookingCargoTons,
|
bookingCargoTons,
|
||||||
bulkItemsFitFor,
|
bulkItemsFitFor,
|
||||||
bulkItemWagonsRequired,
|
bulkItemWagonsRequired,
|
||||||
bulkTonsPerWagon,
|
bulkTonsPerWagon,
|
||||||
|
consistViolations,
|
||||||
deriveTrainCapacityFromLocomotive,
|
deriveTrainCapacityFromLocomotive,
|
||||||
combinedLocomotiveLimits,
|
combinedLocomotiveLimits,
|
||||||
|
trainHardCaps,
|
||||||
trainSetLocomotiveLimits,
|
trainSetLocomotiveLimits,
|
||||||
wagonTypeDimensionsFromEntity,
|
wagonTypeDimensionsFromEntity,
|
||||||
LocomotiveLimits,
|
LocomotiveLimits,
|
||||||
@@ -928,6 +933,68 @@ export class TrainSchedulingService {
|
|||||||
return fresh ?? schedule;
|
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
|
* 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
|
* allowed while the booking window has not opened yet — an OPEN/past schedule
|
||||||
@@ -1115,13 +1182,23 @@ export class TrainSchedulingService {
|
|||||||
? new Date(new Date(schedule.scheduledArrivalDate).getTime() + deltaMs)
|
? new Date(new Date(schedule.scheduledArrivalDate).getTime() + deltaMs)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// PRE_WINDOW only: the stamped open/close were derived from the old
|
// PRE_WINDOW: the stamped open/close were derived from the old departure
|
||||||
// departure and the window hasn't opened yet, so re-derive them from the
|
// and the window hasn't opened yet, so re-derive them from the schedule's
|
||||||
// schedule's own rule snapshot against the new date (joining the target
|
// own rule snapshot against the new date (joining the target day's route
|
||||||
// day's route group timeline when one exists, exactly like
|
// group timeline when one exists, exactly like updateScheduleDate).
|
||||||
// updateScheduleDate). Mid/post-window schedules keep their timeline.
|
//
|
||||||
|
// DONE: the window already finished (e.g. the close offset hit and then the
|
||||||
|
// train was moved to a later departure). The window must follow the new
|
||||||
|
// departure, so it REOPENS: re-derive open/close the same way, reset the
|
||||||
|
// phase to PRE_WINDOW and clamp a past open into the present so the tick
|
||||||
|
// opens it immediately. A FULL train stays closed — there is nothing left
|
||||||
|
// to sell — and so does one whose re-derived window would already be over.
|
||||||
|
//
|
||||||
|
// Mid-window phases (OPEN/DOC_REVIEW/PAYMENT) keep their running timeline.
|
||||||
|
const reopenFromDone =
|
||||||
|
schedule.windowPhase === 'DONE' && schedule.bookingWindowStatus !== 'FULL';
|
||||||
const windowFields =
|
const windowFields =
|
||||||
schedule.windowPhase === 'PRE_WINDOW'
|
schedule.windowPhase === 'PRE_WINDOW' || reopenFromDone
|
||||||
? await (async () => {
|
? await (async () => {
|
||||||
const merged = effectiveWindowConfig(
|
const merged = effectiveWindowConfig(
|
||||||
schedule,
|
schedule,
|
||||||
@@ -1140,12 +1217,33 @@ export class TrainSchedulingService {
|
|||||||
schedule.destinationStationId,
|
schedule.destinationStationId,
|
||||||
departure,
|
departure,
|
||||||
);
|
);
|
||||||
return anchor
|
if (anchor) {
|
||||||
? this.groupWindowFieldsFrom(anchor, departure)
|
// groupWindowFieldsFrom copies the anchor's live phase and
|
||||||
: {
|
// deadlines, so a DONE train joining a live group re-enters the
|
||||||
windowOpensAt: times.windowOpensAt,
|
// group's cycle directly — no extra reset needed.
|
||||||
windowClosesAt: times.windowClosesAt,
|
return this.groupWindowFieldsFrom(anchor, departure);
|
||||||
};
|
}
|
||||||
|
if (!reopenFromDone) {
|
||||||
|
return {
|
||||||
|
windowOpensAt: times.windowOpensAt,
|
||||||
|
windowClosesAt: times.windowClosesAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const now = new Date();
|
||||||
|
const windowOpensAt =
|
||||||
|
times.windowOpensAt < now ? now : times.windowOpensAt;
|
||||||
|
if (times.windowClosesAt.getTime() <= windowOpensAt.getTime()) {
|
||||||
|
return {}; // no window fits before the new departure — stay closed
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
windowOpensAt,
|
||||||
|
windowClosesAt: times.windowClosesAt,
|
||||||
|
windowPhase: 'PRE_WINDOW',
|
||||||
|
bookingWindowStatus: 'CLOSED',
|
||||||
|
docReviewCompletedAt: null,
|
||||||
|
docReviewEndsAt: null,
|
||||||
|
paymentPhaseEndsAt: null,
|
||||||
|
};
|
||||||
})()
|
})()
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
@@ -3262,6 +3360,12 @@ export class TrainSchedulingService {
|
|||||||
* Every export booking being confirmed loaded must already be received at the
|
* Every export booking being confirmed loaded must already be received at the
|
||||||
* warehouse with a GRN. An allocation puts a booking on a wagon on paper; this
|
* warehouse with a GRN. An allocation puts a booking on a wagon on paper; this
|
||||||
* is the check that the cargo is physically in the yard before we call it loaded.
|
* is the check that the cargo is physically in the yard before we call it loaded.
|
||||||
|
*
|
||||||
|
* Direct truck-to-train (exportHandoverMode = DIRECT_TO_TRAIN) is excluded —
|
||||||
|
* that cargo is manually loaded from the customer's truck straight onto the
|
||||||
|
* wagon, never sees the warehouse, and is never GRN'd. Its custody is attested
|
||||||
|
* by the carriage acceptance sheet instead (same carve-out as the shared
|
||||||
|
* assertExportReceivedWithGrn gate — see common/export-received-gate.ts).
|
||||||
*/
|
*/
|
||||||
private async assertExportBookingsReceived(bookingIds: string[]): Promise<void> {
|
private async assertExportBookingsReceived(bookingIds: string[]): Promise<void> {
|
||||||
if (!bookingIds.length) return;
|
if (!bookingIds.length) return;
|
||||||
@@ -3270,6 +3374,7 @@ export class TrainSchedulingService {
|
|||||||
FROM freight.bookings b
|
FROM freight.bookings b
|
||||||
WHERE b.id = ANY($1)
|
WHERE b.id = ANY($1)
|
||||||
AND b.deleted_at IS NULL
|
AND b.deleted_at IS NULL
|
||||||
|
AND b.export_handover_mode IS DISTINCT FROM 'DIRECT_TO_TRAIN'
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM freight.warehouse_inventory inv
|
SELECT 1 FROM freight.warehouse_inventory inv
|
||||||
WHERE inv.booking_id = b.id
|
WHERE inv.booking_id = b.id
|
||||||
@@ -4072,7 +4177,15 @@ export class TrainSchedulingService {
|
|||||||
const [schedules, total] = await this.trainSchedulesRepository.findAndCount({
|
const [schedules, total] = await this.trainSchedulesRepository.findAndCount({
|
||||||
where,
|
where,
|
||||||
relations: {
|
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;
|
// Yards carry the route's display name used by mapScheduleListItem;
|
||||||
// milestones (with yards) let it show the full corridor path.
|
// milestones (with yards) let it show the full corridor path.
|
||||||
route: { originYard: true, destinationYard: true, milestones: { yard: true } },
|
route: { originYard: true, destinationYard: true, milestones: { yard: true } },
|
||||||
@@ -5752,12 +5865,22 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private mapScheduleListItem(schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule) {
|
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 {
|
return {
|
||||||
id: schedule.id,
|
id: schedule.id,
|
||||||
reference: schedule.reference ?? null,
|
reference: schedule.reference ?? null,
|
||||||
createdAt: schedule.createdAt ?? null,
|
createdAt: schedule.createdAt ?? null,
|
||||||
scheduleDate: schedule.scheduledDepartureDate,
|
scheduleDate: schedule.scheduledDepartureDate,
|
||||||
trainNumber: schedule.trainNumber ?? null,
|
trainNumber: schedule.trainNumber ?? null,
|
||||||
|
voyageNumber: schedule.voyageNumber ?? null,
|
||||||
direction: schedule.direction ?? null,
|
direction: schedule.direction ?? null,
|
||||||
routeName: schedule.route ? formatRouteLabel(schedule.route) : null,
|
routeName: schedule.route ? formatRouteLabel(schedule.route) : null,
|
||||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||||
@@ -5786,6 +5909,14 @@ export class TrainSchedulingService {
|
|||||||
currentYardId: loco.currentYardId ?? null,
|
currentYardId: loco.currentYardId ?? null,
|
||||||
})),
|
})),
|
||||||
wagonCount: schedule.trainSet?.wagonCount ?? 0,
|
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)),
|
totalWeightTons: roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)),
|
||||||
totalLengthMeters: roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)),
|
totalLengthMeters: roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)),
|
||||||
bookingsCount: schedule.scheduleBookings?.length ?? 0,
|
bookingsCount: schedule.scheduleBookings?.length ?? 0,
|
||||||
@@ -7703,6 +7834,7 @@ export class TrainSchedulingService {
|
|||||||
status: schedule.status,
|
status: schedule.status,
|
||||||
freightType: this.resolveScheduleFreightType(schedule),
|
freightType: this.resolveScheduleFreightType(schedule),
|
||||||
trainNumber: schedule.trainNumber ?? null,
|
trainNumber: schedule.trainNumber ?? null,
|
||||||
|
voyageNumber: schedule.voyageNumber ?? null,
|
||||||
maxWagons: schedule.maxWagons ?? null,
|
maxWagons: schedule.maxWagons ?? null,
|
||||||
direction: schedule.direction ?? null,
|
direction: schedule.direction ?? null,
|
||||||
reverseWagonOrder: schedule.reverseWagonOrder ?? false,
|
reverseWagonOrder: schedule.reverseWagonOrder ?? false,
|
||||||
@@ -9122,4 +9254,376 @@ export class TrainSchedulingService {
|
|||||||
});
|
});
|
||||||
return new Set(allocations.map((a) => a.bookingId));
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -51,9 +51,9 @@ export class BuildTrainDto {
|
|||||||
@IsUUID('all', { each: true })
|
@IsUUID('all', { each: true })
|
||||||
wagonIds?: string[];
|
wagonIds?: string[];
|
||||||
|
|
||||||
@ApiProperty({ maxLength: 100, description: 'Vogue number' })
|
@ApiProperty({ maxLength: 100, description: 'Voyage number' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty({ message: 'Vogue number is required' })
|
@IsNotEmpty({ message: 'Voyage number is required' })
|
||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
trainName!: string;
|
trainName!: string;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
|
|||||||
import { BuildTrainDto } from './dto/build-train.dto';
|
import { BuildTrainDto } from './dto/build-train.dto';
|
||||||
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
||||||
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.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 { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
|
||||||
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
||||||
import { UpdateTrainYardDto } from './dto/update-train-yard.dto';
|
import { UpdateTrainYardDto } from './dto/update-train-yard.dto';
|
||||||
@@ -39,6 +40,10 @@ import { TrainBuilderService } from './train-builder.service';
|
|||||||
FREIGHT_PERMS.trains.update,
|
FREIGHT_PERMS.trains.update,
|
||||||
FREIGHT_PERMS.trains.assignWagons,
|
FREIGHT_PERMS.trains.assignWagons,
|
||||||
FREIGHT_PERMS.trains.delete,
|
FREIGHT_PERMS.trains.delete,
|
||||||
|
FREIGHT_PERMS.trains.changeLocomotives,
|
||||||
|
FREIGHT_PERMS.trains.changeYard,
|
||||||
|
FREIGHT_PERMS.trains.toggleActive,
|
||||||
|
FREIGHT_PERMS.trains.disband,
|
||||||
])
|
])
|
||||||
export class TrainBuilderController {
|
export class TrainBuilderController {
|
||||||
constructor(private readonly trainBuilderService: TrainBuilderService) {}
|
constructor(private readonly trainBuilderService: TrainBuilderService) {}
|
||||||
@@ -72,7 +77,7 @@ export class TrainBuilderController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id/locomotives')
|
@Put(':id/locomotives')
|
||||||
@FleetManage(FREIGHT_PERMS.trains.update)
|
@FleetManage(FREIGHT_PERMS.trains.changeLocomotives)
|
||||||
@ApiOperation({ summary: 'Replace the locomotive set (minimum 1, same yard)' })
|
@ApiOperation({ summary: 'Replace the locomotive set (minimum 1, same yard)' })
|
||||||
setLocomotives(
|
setLocomotives(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@@ -94,7 +99,7 @@ export class TrainBuilderController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/yard')
|
@Patch(':id/yard')
|
||||||
@FleetManage(FREIGHT_PERMS.trains.update)
|
@FleetManage(FREIGHT_PERMS.trains.changeYard)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Relocate the train — its locomotives and wagons move to the new yard with it',
|
summary: 'Relocate the train — its locomotives and wagons move to the new yard with it',
|
||||||
})
|
})
|
||||||
@@ -131,8 +136,14 @@ export class TrainBuilderController {
|
|||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Param('wagonId', ParseUUIDPipe) wagonId: string,
|
@Param('wagonId', ParseUUIDPipe) wagonId: string,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@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')
|
@Post(':id/reorder-wagons')
|
||||||
@@ -143,7 +154,7 @@ export class TrainBuilderController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/deactivate')
|
@Post(':id/deactivate')
|
||||||
@FleetManage(FREIGHT_PERMS.trains.update)
|
@FleetManage(FREIGHT_PERMS.trains.toggleActive)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Deactivate the train (park it) — only allowed with no active schedule',
|
summary: 'Deactivate the train (park it) — only allowed with no active schedule',
|
||||||
})
|
})
|
||||||
@@ -152,14 +163,14 @@ export class TrainBuilderController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/activate')
|
@Post(':id/activate')
|
||||||
@FleetManage(FREIGHT_PERMS.trains.update)
|
@FleetManage(FREIGHT_PERMS.trains.toggleActive)
|
||||||
@ApiOperation({ summary: 'Reactivate a deactivated train back to AVAILABLE' })
|
@ApiOperation({ summary: 'Reactivate a deactivated train back to AVAILABLE' })
|
||||||
activate(@Param('id', ParseUUIDPipe) id: string) {
|
activate(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.trainBuilderService.activate(id);
|
return this.trainBuilderService.activate(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@FleetManage(FREIGHT_PERMS.trains.delete)
|
@FleetManage(FREIGHT_PERMS.trains.disband)
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Disband the train (release wagons and locomotives)' })
|
@ApiOperation({ summary: 'Disband the train (release wagons and locomotives)' })
|
||||||
disband(@Param('id', ParseUUIDPipe) id: string) {
|
disband(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,6 +19,7 @@ import { TrainSet } from '../train-sets/entities/train-set.entity';
|
|||||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
import { WagonMovement } from '../wagons/entities/wagon-movement.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 { Wagon } from '../wagons/entities/wagon.entity';
|
||||||
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
|
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
|
||||||
import { BuildTrainDto } from './dto/build-train.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
|
* moves to MAINTENANCE status (not AVAILABLE), so it is not re-coupled until
|
||||||
* it clears maintenance. The freed sequence gap is closed.
|
* 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) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
const train = await this.getEditableTrain(manager, id);
|
const train = await this.getEditableTrain(manager, id);
|
||||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
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`,
|
`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, {
|
await manager.getRepository(Wagon).update(wagon.id, {
|
||||||
trainId: null,
|
trainId: null,
|
||||||
sequenceNumber: null,
|
sequenceNumber: null,
|
||||||
@@ -549,6 +557,22 @@ export class TrainBuilderService {
|
|||||||
importTrainNumber: null,
|
importTrainNumber: null,
|
||||||
exportTrainNumber: 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
|
// 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
|
// 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.
|
// surface, and a maintenance detach has to be in it.
|
||||||
@@ -560,7 +584,7 @@ export class TrainBuilderService {
|
|||||||
fromYardId: yardId,
|
fromYardId: yardId,
|
||||||
toYardId: yardId,
|
toYardId: yardId,
|
||||||
kind: WagonMovementKind.Maintenance,
|
kind: WagonMovementKind.Maintenance,
|
||||||
note: `Sent to maintenance from train ${train.trainNumber ?? train.code}`,
|
note: notes.movementNote,
|
||||||
occurredAt: new Date(),
|
occurredAt: new Date(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -1111,6 +1135,48 @@ export class TrainBuilderService {
|
|||||||
* pinned to a reordered wagon adopt the wagon's new position; unpinned slots
|
* pinned to a reordered wagon adopt the wagon's new position; unpinned slots
|
||||||
* trail behind in their previous relative order.
|
* 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<
|
export function orderSlotsByWagonSequence<
|
||||||
T extends Pick<TrainSetWagon, 'sequenceNo' | 'physicalWagonId'>,
|
T extends Pick<TrainSetWagon, 'sequenceNo' | 'physicalWagonId'>,
|
||||||
>(slots: T[], newSeq: Map<string, number>): T[] {
|
>(slots: T[], newSeq: Map<string, number>): T[] {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import {
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
IsArray,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
@@ -11,11 +13,14 @@ import {
|
|||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A count-only wagon-transfer request. The requester picks source yard, wagon
|
* A wagon-transfer request. The requester picks source yard, wagon type,
|
||||||
* type, destination yard and HOW MANY — never the specific wagons; OCC hand-picks
|
* destination yard and HOW MANY. The quantity may not exceed the AVAILABLE
|
||||||
* those at fulfilment. The quantity may not exceed the AVAILABLE wagons of that
|
* wagons of that type currently in the source yard (enforced in the service,
|
||||||
* type currently in the source yard (enforced in the service, which is the only
|
* which is the only layer that can count them), and a reason is mandatory.
|
||||||
* 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 {
|
export class CreateTransferRequestDto {
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
@@ -38,6 +43,17 @@ export class CreateTransferRequestDto {
|
|||||||
@MaxLength(2000)
|
@MaxLength(2000)
|
||||||
reason!: string;
|
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' })
|
@ApiPropertyOptional({ description: 'Optional note for the fulfilling staff' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ export class WagonTransferRequest extends BaseEntity {
|
|||||||
})
|
})
|
||||||
status!: WagonTransferRequestStatus;
|
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 })
|
@Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
|
||||||
requestedByUserId?: string | null;
|
requestedByUserId?: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,19 @@ describe('WagonTransferRequestsService — partial fulfilment', () => {
|
|||||||
expect(result.skipped).toHaveLength(0);
|
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 () => {
|
it('skips only when the yard has nothing to give', async () => {
|
||||||
wagonRepo.find.mockResolvedValue([]);
|
wagonRepo.find.mockResolvedValue([]);
|
||||||
|
|
||||||
@@ -253,6 +266,84 @@ describe('WagonTransferRequestsService — partial fulfilment', () => {
|
|||||||
expect(requestRepo.save).not.toHaveBeenCalled();
|
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 () => {
|
it('still refuses a same-yard move', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
service.createRequest(
|
service.createRequest(
|
||||||
|
|||||||
@@ -27,6 +27,15 @@ import { WagonMovement } from './entities/wagon-movement.entity';
|
|||||||
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
||||||
import { WagonsService } from './wagons.service';
|
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. */
|
/** Bundled per-user activity: requests they touched + wagons they moved. */
|
||||||
export interface TransferHistory {
|
export interface TransferHistory {
|
||||||
requests: WagonTransferRequest[];
|
requests: WagonTransferRequest[];
|
||||||
@@ -75,11 +84,15 @@ export class WagonTransferRequestsService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record a PENDING request. Count-only — no wagons are picked here, but the
|
* Record a PENDING request. The count is capped by what the source yard can
|
||||||
* count IS capped by what the source yard can hand over right now: a request
|
* hand over right now: a request may not exceed the AVAILABLE, uncoupled
|
||||||
* may not exceed the AVAILABLE, uncoupled wagons of that type in the source
|
* wagons of that type in the source yard (the same number the yard desk
|
||||||
* yard (the same number the yard desk shows). A reason is mandatory and is
|
* shows). A reason is mandatory and is shown on the OCC queue.
|
||||||
* 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(
|
async createRequest(
|
||||||
dto: CreateTransferRequestDto,
|
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}`,
|
`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({
|
const request = this.requestRepo.create({
|
||||||
fromYardId: dto.fromYardId,
|
fromYardId: dto.fromYardId,
|
||||||
toYardId: dto.toYardId,
|
toYardId: dto.toYardId,
|
||||||
wagonTypeId: dto.wagonTypeId,
|
wagonTypeId: dto.wagonTypeId,
|
||||||
quantity: dto.quantity,
|
quantity: dto.quantity,
|
||||||
|
preferredWagonIds,
|
||||||
status: WagonTransferRequestStatus.Pending,
|
status: WagonTransferRequestStatus.Pending,
|
||||||
requestedByUserId: userId ?? null,
|
requestedByUserId: userId ?? null,
|
||||||
reason: dto.reason,
|
reason: dto.reason,
|
||||||
@@ -118,6 +135,45 @@ export class WagonTransferRequestsService {
|
|||||||
return this.findById(saved.id);
|
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
|
* 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
|
* right now. Shown on the desk beside the outstanding count so staff see at a
|
||||||
@@ -179,16 +235,51 @@ export class WagonTransferRequestsService {
|
|||||||
: 'r.createdAt';
|
: 'r.createdAt';
|
||||||
qb.orderBy(sortColumn, query.sortOrder ?? 'DESC');
|
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({
|
const request = await this.requestRepo.findOne({
|
||||||
where: { id },
|
where: { id },
|
||||||
relations: REQUEST_RELATIONS,
|
relations: REQUEST_RELATIONS,
|
||||||
});
|
});
|
||||||
if (!request) throw new NotFoundException(`Transfer request ${id} not found`);
|
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. */
|
/** Wagons still owed on an open request. */
|
||||||
@@ -411,7 +502,7 @@ export class WagonTransferRequestsService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const remaining = this.remainingOn(request);
|
const remaining = this.remainingOn(request);
|
||||||
const wagons = await this.wagonRepo.find({
|
const candidates = await this.wagonRepo.find({
|
||||||
where: {
|
where: {
|
||||||
currentYardId: request.fromYardId,
|
currentYardId: request.fromYardId,
|
||||||
wagonTypeId: request.wagonTypeId,
|
wagonTypeId: request.wagonTypeId,
|
||||||
@@ -419,8 +510,19 @@ export class WagonTransferRequestsService {
|
|||||||
trainId: IsNull(),
|
trainId: IsNull(),
|
||||||
},
|
},
|
||||||
order: { wagonNumber: 'ASC' },
|
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) {
|
if (wagons.length === 0) {
|
||||||
skipped.push({
|
skipped.push({
|
||||||
id,
|
id,
|
||||||
@@ -479,7 +581,7 @@ export class WagonTransferRequestsService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
requests,
|
requests: await this.withPreferredWagons(requests),
|
||||||
movements,
|
movements,
|
||||||
meta: {
|
meta: {
|
||||||
page: page ?? 1,
|
page: page ?? 1,
|
||||||
|
|||||||
@@ -191,6 +191,24 @@ const POSITION_TYPE_PERMISSIONS = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] 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 = [
|
const EDR_FREIGHT_VIEWABLE_PERMISSIONS = [
|
||||||
...EMPLOYEE_REGISTRATION_PERMISSIONS,
|
...EMPLOYEE_REGISTRATION_PERMISSIONS,
|
||||||
...ROLE_ASSIGNMENT_PERMISSIONS,
|
...ROLE_ASSIGNMENT_PERMISSIONS,
|
||||||
@@ -198,6 +216,7 @@ const EDR_FREIGHT_VIEWABLE_PERMISSIONS = [
|
|||||||
...HIERARCHY_POSITION_PERMISSIONS,
|
...HIERARCHY_POSITION_PERMISSIONS,
|
||||||
...HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS,
|
...HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS,
|
||||||
...POSITION_TYPE_PERMISSIONS,
|
...POSITION_TYPE_PERMISSIONS,
|
||||||
|
...AUDIT_LOG_PERMISSIONS,
|
||||||
...BOOKING_RULE_ENGINE_PERMISSIONS,
|
...BOOKING_RULE_ENGINE_PERMISSIONS,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -707,6 +707,29 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
"edr_freight_app:trains:assign_wagons",
|
"edr_freight_app:trains:assign_wagons",
|
||||||
"Assign wagons to train",
|
"Assign wagons to train",
|
||||||
),
|
),
|
||||||
|
// Granular splits of trains:update / trains:delete for the train-builder
|
||||||
|
// detail page's Actions menu — each item gets its own grant instead of
|
||||||
|
// sharing the coarse update/delete keys.
|
||||||
|
perm(
|
||||||
|
"e1c00001-0001-4000-8000-000000000006",
|
||||||
|
"edr_freight_app:trains:change_locomotives",
|
||||||
|
"Change train locomotives",
|
||||||
|
),
|
||||||
|
perm(
|
||||||
|
"e1c00001-0001-4000-8000-000000000007",
|
||||||
|
"edr_freight_app:trains:change_yard",
|
||||||
|
"Change train yard",
|
||||||
|
),
|
||||||
|
perm(
|
||||||
|
"e1c00001-0001-4000-8000-000000000008",
|
||||||
|
"edr_freight_app:trains:toggle_active",
|
||||||
|
"Activate or deactivate train",
|
||||||
|
),
|
||||||
|
perm(
|
||||||
|
"e1c00001-0001-4000-8000-000000000009",
|
||||||
|
"edr_freight_app:trains:disband",
|
||||||
|
"Disband train",
|
||||||
|
),
|
||||||
perm(
|
perm(
|
||||||
"e1d00001-0001-4000-8000-000000000001",
|
"e1d00001-0001-4000-8000-000000000001",
|
||||||
"edr_freight_app:routes:view",
|
"edr_freight_app:routes:view",
|
||||||
@@ -1622,11 +1645,24 @@ export const FREIGHT_PERMS = {
|
|||||||
dispatch: "edr_freight_app:train_scheduling:dispatch",
|
dispatch: "edr_freight_app:train_scheduling:dispatch",
|
||||||
markPaid: "edr_freight_app:train_scheduling:mark_paid",
|
markPaid: "edr_freight_app:train_scheduling:mark_paid",
|
||||||
expireBooking: "edr_freight_app:train_scheduling:expire_booking",
|
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: {
|
fleet: {
|
||||||
view: "edr_freight_app:fleet:view",
|
view: "edr_freight_app:fleet:view",
|
||||||
manage: "edr_freight_app:fleet:manage",
|
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",
|
admin: "edr_freight_app:admin",
|
||||||
ruleEngine: {
|
ruleEngine: {
|
||||||
view: (slug: RuleEngineResourceSlug) =>
|
view: (slug: RuleEngineResourceSlug) =>
|
||||||
@@ -1732,6 +1768,10 @@ export const FREIGHT_PERMS = {
|
|||||||
update: "edr_freight_app:trains:update",
|
update: "edr_freight_app:trains:update",
|
||||||
delete: "edr_freight_app:trains:delete",
|
delete: "edr_freight_app:trains:delete",
|
||||||
assignWagons: "edr_freight_app:trains:assign_wagons",
|
assignWagons: "edr_freight_app:trains:assign_wagons",
|
||||||
|
changeLocomotives: "edr_freight_app:trains:change_locomotives",
|
||||||
|
changeYard: "edr_freight_app:trains:change_yard",
|
||||||
|
toggleActive: "edr_freight_app:trains:toggle_active",
|
||||||
|
disband: "edr_freight_app:trains:disband",
|
||||||
},
|
},
|
||||||
routes: {
|
routes: {
|
||||||
view: "edr_freight_app:routes:view",
|
view: "edr_freight_app:routes:view",
|
||||||
@@ -1895,9 +1935,6 @@ export const FREIGHT_PERMS = {
|
|||||||
manage: "edr_freight_app:settings:support_content:manage",
|
manage: "edr_freight_app:settings:support_content:manage",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
audit: {
|
|
||||||
view: "edr_freight_app:audit:view",
|
|
||||||
},
|
|
||||||
support: {
|
support: {
|
||||||
agentView: "edr_freight_app:support:agent_view",
|
agentView: "edr_freight_app:support:agent_view",
|
||||||
agentSend: "edr_freight_app:support:agent_send",
|
agentSend: "edr_freight_app:support:agent_send",
|
||||||
@@ -2049,6 +2086,10 @@ const FLEET_GRANULAR_KEYS: string[] = [
|
|||||||
FREIGHT_PERMS.trains.update,
|
FREIGHT_PERMS.trains.update,
|
||||||
FREIGHT_PERMS.trains.delete,
|
FREIGHT_PERMS.trains.delete,
|
||||||
FREIGHT_PERMS.trains.assignWagons,
|
FREIGHT_PERMS.trains.assignWagons,
|
||||||
|
FREIGHT_PERMS.trains.changeLocomotives,
|
||||||
|
FREIGHT_PERMS.trains.changeYard,
|
||||||
|
FREIGHT_PERMS.trains.toggleActive,
|
||||||
|
FREIGHT_PERMS.trains.disband,
|
||||||
FREIGHT_PERMS.routes.view,
|
FREIGHT_PERMS.routes.view,
|
||||||
FREIGHT_PERMS.routes.create,
|
FREIGHT_PERMS.routes.create,
|
||||||
FREIGHT_PERMS.routes.update,
|
FREIGHT_PERMS.routes.update,
|
||||||
@@ -2129,6 +2170,7 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
FREIGHT_PERMS.trainScheduling.dispatch,
|
FREIGHT_PERMS.trainScheduling.dispatch,
|
||||||
FREIGHT_PERMS.trainScheduling.markPaid,
|
FREIGHT_PERMS.trainScheduling.markPaid,
|
||||||
FREIGHT_PERMS.trainScheduling.expireBooking,
|
FREIGHT_PERMS.trainScheduling.expireBooking,
|
||||||
|
FREIGHT_PERMS.trainScheduling.editTrainNumber,
|
||||||
FREIGHT_PERMS.fleet.view,
|
FREIGHT_PERMS.fleet.view,
|
||||||
FREIGHT_PERMS.fleet.manage,
|
FREIGHT_PERMS.fleet.manage,
|
||||||
...FLEET_GRANULAR_KEYS,
|
...FLEET_GRANULAR_KEYS,
|
||||||
@@ -2284,7 +2326,8 @@ export const POSITION_PERMISSION_PRESETS = {
|
|||||||
FREIGHT_PERMS.payments.view,
|
FREIGHT_PERMS.payments.view,
|
||||||
]),
|
]),
|
||||||
// Director additionally manages train scheduling + rail fleet (same block the
|
// Director additionally manages train scheduling + rail fleet (same block the
|
||||||
// operation officer/chief hold), on top of the approval-chain role preset.
|
// operation officer/chief hold), on top of the approval-chain role preset,
|
||||||
|
// and carries the same full warehouse authority the chief tier holds.
|
||||||
director: dedupe([
|
director: dedupe([
|
||||||
...ROLE_PERMISSION_PRESETS.director,
|
...ROLE_PERMISSION_PRESETS.director,
|
||||||
FREIGHT_PERMS.trainScheduling.view,
|
FREIGHT_PERMS.trainScheduling.view,
|
||||||
@@ -2293,9 +2336,22 @@ export const POSITION_PERMISSION_PRESETS = {
|
|||||||
FREIGHT_PERMS.trainScheduling.cancel,
|
FREIGHT_PERMS.trainScheduling.cancel,
|
||||||
FREIGHT_PERMS.trainScheduling.reschedule,
|
FREIGHT_PERMS.trainScheduling.reschedule,
|
||||||
FREIGHT_PERMS.trainScheduling.rulesManage,
|
FREIGHT_PERMS.trainScheduling.rulesManage,
|
||||||
|
FREIGHT_PERMS.trainScheduling.editTrainNumber,
|
||||||
FREIGHT_PERMS.fleet.view,
|
FREIGHT_PERMS.fleet.view,
|
||||||
FREIGHT_PERMS.fleet.manage,
|
FREIGHT_PERMS.fleet.manage,
|
||||||
...FLEET_GRANULAR_KEYS,
|
...FLEET_GRANULAR_KEYS,
|
||||||
|
// Warehouse — full CRUD, matching the chief tier. Unlike the dispatcher,
|
||||||
|
// the director also owns the allocation and fee rules themselves.
|
||||||
|
FREIGHT_PERMS.warehouseDashboard.view,
|
||||||
|
...Object.values(FREIGHT_PERMS.warehouses),
|
||||||
|
...Object.values(FREIGHT_PERMS.warehouseYards),
|
||||||
|
...Object.values(FREIGHT_PERMS.warehouseZones),
|
||||||
|
...Object.values(FREIGHT_PERMS.warehouseAllocationRules),
|
||||||
|
...Object.values(FREIGHT_PERMS.warehouseFeeRules),
|
||||||
|
...Object.values(FREIGHT_PERMS.warehouseInventory),
|
||||||
|
...Object.values(FREIGHT_PERMS.warehouseInspectionReports),
|
||||||
|
...Object.values(FREIGHT_PERMS.interchangeDocuments),
|
||||||
|
...Object.values(FREIGHT_PERMS.warehouseFeeInvoices),
|
||||||
]),
|
]),
|
||||||
ceo: dedupe([...ROLE_PERMISSION_PRESETS.ceo]),
|
ceo: dedupe([...ROLE_PERMISSION_PRESETS.ceo]),
|
||||||
ethiopianGl: dedupe([...ROLE_PERMISSION_PRESETS.glEthiopia]),
|
ethiopianGl: dedupe([...ROLE_PERMISSION_PRESETS.glEthiopia]),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
|
Application,
|
||||||
Organization,
|
Organization,
|
||||||
Permission,
|
Permission,
|
||||||
Position,
|
Position,
|
||||||
@@ -8,7 +9,23 @@ import {
|
|||||||
} from '@tria-plc/iamapi-common';
|
} from '@tria-plc/iamapi-common';
|
||||||
import { DataSource, EntityManager, In } from 'typeorm';
|
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 SEED_FLAG = 'SEED_EDR_ORG';
|
||||||
const EDR_ORG_KEY = 'edr_freight';
|
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(
|
private async loadPermissionIds(
|
||||||
manager: EntityManager,
|
manager: EntityManager,
|
||||||
): Promise<Map<string, string>> {
|
): Promise<Map<string, string>> {
|
||||||
@@ -91,16 +120,54 @@ export class FreightPositionsSeeder {
|
|||||||
...new Set(EDR_FREIGHT_POSITIONS.flatMap((p) => p.permissionKeys)),
|
...new Set(EDR_FREIGHT_POSITIONS.flatMap((p) => p.permissionKeys)),
|
||||||
];
|
];
|
||||||
|
|
||||||
const permissions = await manager.getRepository(Permission).find({
|
const read = async () => {
|
||||||
where: { key: In(keys) },
|
const rows = await manager.getRepository(Permission).find({
|
||||||
select: { id: true, key: true },
|
where: { key: In(keys) },
|
||||||
});
|
select: { id: true, key: true },
|
||||||
|
});
|
||||||
const map = new Map(permissions.map((p) => [p.key, p.id as string]));
|
return new Map(rows.map((p) => [p.key, p.id as string]));
|
||||||
|
};
|
||||||
|
|
||||||
|
let map = await read();
|
||||||
const missing = keys.filter((key) => !map.has(key));
|
const missing = keys.filter((key) => !map.has(key));
|
||||||
if (missing.length > 0) {
|
if (missing.length === 0) {
|
||||||
throw new Error(`missing_permissions:${missing.join(',')}`);
|
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;
|
return map;
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
|||||||
import OverviewPage from "./pages/dashboard/OverviewPage";
|
import OverviewPage from "./pages/dashboard/OverviewPage";
|
||||||
import ReportsHubPage from "./pages/reports/ReportsHubPage";
|
import ReportsHubPage from "./pages/reports/ReportsHubPage";
|
||||||
import ReportPage from "./pages/reports/ReportPage";
|
import ReportPage from "./pages/reports/ReportPage";
|
||||||
|
import AuditLogsPage from "./pages/AuditLogsPage";
|
||||||
import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage";
|
import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage";
|
||||||
import PaymentsPage from "./pages/payments/PaymentsPage";
|
import PaymentsPage from "./pages/payments/PaymentsPage";
|
||||||
import AuditLogsPage from "./pages/audit/AuditLogsPage";
|
|
||||||
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
||||||
import { RequirePermission } from "./components/auth/RequirePermission";
|
import { RequirePermission } from "./components/auth/RequirePermission";
|
||||||
import { FREIGHT_PERMS } from "./lib/permissions";
|
import { FREIGHT_PERMS } from "./lib/permissions";
|
||||||
@@ -205,6 +205,7 @@ const App = () => {
|
|||||||
<Route path="overview" element={<RequirePermission permission={FREIGHT_PERMS.overview.view}><OverviewPage /></RequirePermission>} />
|
<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" element={<RequirePermission permission={FREIGHT_PERMS.reports.view}><ReportsHubPage /></RequirePermission>} />
|
||||||
<Route path="reports/:reportKey" element={<RequirePermission permission={FREIGHT_PERMS.reports.view}><ReportPage /></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. */}
|
{/* Dev/testing page for the mock AI booking assistant. */}
|
||||||
<Route
|
<Route
|
||||||
path="ai-booking-mock-test"
|
path="ai-booking-mock-test"
|
||||||
@@ -803,14 +804,6 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="audit-logs"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.audit.view}>
|
|
||||||
<AuditLogsPage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="contract-templates"
|
path="contract-templates"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -142,6 +142,21 @@ export function BookingSchedulingWindowCard({
|
|||||||
schedule?.reference ??
|
schedule?.reference ??
|
||||||
(schedule ? "Assigned train" : null);
|
(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 (
|
return (
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={CalendarClock}
|
icon={CalendarClock}
|
||||||
@@ -153,7 +168,7 @@ export function BookingSchedulingWindowCard({
|
|||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
{trainLabel ? (
|
{trainLabel ? (
|
||||||
<Row
|
<Row
|
||||||
label="Scheduled on train"
|
label={trainRowLabel}
|
||||||
value={trainLabel}
|
value={trainLabel}
|
||||||
hint={
|
hint={
|
||||||
schedule?.reference && schedule.reference !== trainLabel
|
schedule?.reference && schedule.reference !== trainLabel
|
||||||
@@ -163,13 +178,21 @@ export function BookingSchedulingWindowCard({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Row
|
<Row
|
||||||
label="Scheduled on train"
|
label={trainRowLabel}
|
||||||
value="Not yet allocated"
|
value="Not yet allocated"
|
||||||
tone="muted"
|
tone="muted"
|
||||||
hint="The booking has not been placed on a train schedule"
|
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 ? (
|
{schedule?.status ? (
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
@@ -231,10 +254,15 @@ export function BookingSchedulingWindowCard({
|
|||||||
formatStamp(schedule.scheduledDepartureDate) ??
|
formatStamp(schedule.scheduledDepartureDate) ??
|
||||||
"—"
|
"—"
|
||||||
}
|
}
|
||||||
|
tone={isRequested && hasDeparted ? "danger" : undefined}
|
||||||
hint={
|
hint={
|
||||||
schedule.actualDepartureAt
|
schedule.actualDepartureAt
|
||||||
? `Actual · planned ${formatStamp(schedule.scheduledDepartureDate) ?? "—"}`
|
? `Actual · planned ${formatStamp(schedule.scheduledDepartureDate) ?? "—"}`
|
||||||
: "Planned"
|
: departureIso && !hasDeparted
|
||||||
|
? `Planned · departs ${formatRelative(departureIso, nowMs)}`
|
||||||
|
: hasDeparted
|
||||||
|
? "Planned — already past"
|
||||||
|
: "Planned"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Row
|
<Row
|
||||||
|
|||||||
@@ -1006,7 +1006,7 @@ export function ReleaseOrderCard({
|
|||||||
clearance: ClearanceViewLike & { vesselDepartureDate?: string | null };
|
clearance: ClearanceViewLike & { vesselDepartureDate?: string | null };
|
||||||
onChanged?: () => void;
|
onChanged?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
const [vesselDate, setVesselDate] = useState<Date | null>(
|
const [vesselDate, setVesselDate] = useState<Date | null>(
|
||||||
clearance.vesselDepartureDate ? new Date(clearance.vesselDepartureDate) : null,
|
clearance.vesselDepartureDate ? new Date(clearance.vesselDepartureDate) : null,
|
||||||
);
|
);
|
||||||
@@ -1020,7 +1020,14 @@ export function ReleaseOrderCard({
|
|||||||
Release Order
|
Release Order
|
||||||
</Text>
|
</Text>
|
||||||
<Stack gap="sm">
|
<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
|
<DateInput
|
||||||
label="Vessel departure date"
|
label="Vessel departure date"
|
||||||
value={vesselDate}
|
value={vesselDate}
|
||||||
@@ -1032,15 +1039,15 @@ export function ReleaseOrderCard({
|
|||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={!file || !vesselDate}
|
disabled={files.length === 0 || !vesselDate}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!file || !vesselDate) return;
|
if (files.length === 0 || !vesselDate) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const iso = vesselDate.toISOString().slice(0, 10);
|
const iso = vesselDate.toISOString().slice(0, 10);
|
||||||
const result = isBooking
|
const result = isBooking
|
||||||
? await bookingsService.uploadReleaseOrder(entityId, file, iso)
|
? await bookingsService.uploadReleaseOrder(entityId, files, iso)
|
||||||
: await contractsService.uploadReleaseOrder(entityId, file, iso);
|
: await contractsService.uploadReleaseOrder(entityId, files, iso);
|
||||||
if (result.hold) {
|
if (result.hold) {
|
||||||
toast.error(result.holdReason ?? "Vessel date too soon");
|
toast.error(result.holdReason ?? "Vessel date too soon");
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -10,11 +10,10 @@ import {
|
|||||||
toIsoDate,
|
toIsoDate,
|
||||||
useDoCollectionDates,
|
useDoCollectionDates,
|
||||||
} from "@/components/contracts/DoCollectionDateFields";
|
} from "@/components/contracts/DoCollectionDateFields";
|
||||||
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
import { PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||||
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
|
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
import { bookingsService } from "@/services/bookings.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";
|
export type GlClearanceUploadKind = "do" | "ro";
|
||||||
|
|
||||||
@@ -44,9 +43,8 @@ export function GlClearanceUploadModal({
|
|||||||
vesselArrivalDate,
|
vesselArrivalDate,
|
||||||
doCollectedDate,
|
doCollectedDate,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onPreview,
|
|
||||||
}: GlClearanceUploadModalProps) {
|
}: GlClearanceUploadModalProps) {
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
const [vesselDate, setVesselDate] = useState<Date | null>(
|
const [vesselDate, setVesselDate] = useState<Date | null>(
|
||||||
vesselDepartureDate ? new Date(vesselDepartureDate) : null,
|
vesselDepartureDate ? new Date(vesselDepartureDate) : null,
|
||||||
);
|
);
|
||||||
@@ -66,16 +64,16 @@ export function GlClearanceUploadModal({
|
|||||||
const isDo = kind === "do";
|
const isDo = kind === "do";
|
||||||
const isRo = kind === "ro";
|
const isRo = kind === "ro";
|
||||||
const replaceMode = isDo
|
const replaceMode = isDo
|
||||||
? Boolean(findWorkflowFile(workflowFiles, "delivery_order"))
|
? workflowFiles.some((f) => isDeliveryOrderFileCode(f.code) && f.file)
|
||||||
: Boolean(findWorkflowFile(workflowFiles, "release_order"));
|
: workflowFiles.some((f) => isReleaseOrderFileCode(f.code) && f.file);
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
setFile(null);
|
setFiles([]);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!file || !kind) return;
|
if (files.length === 0 || !kind) return;
|
||||||
if (isRo && !vesselDate) {
|
if (isRo && !vesselDate) {
|
||||||
toast.error("Vessel departure date is required.");
|
toast.error("Vessel departure date is required.");
|
||||||
return;
|
return;
|
||||||
@@ -93,23 +91,23 @@ export function GlClearanceUploadModal({
|
|||||||
doCollectedDate: toIsoDate(doDates.doCollected)!,
|
doCollectedDate: toIsoDate(doDates.doCollected)!,
|
||||||
};
|
};
|
||||||
if (isBooking) {
|
if (isBooking) {
|
||||||
await bookingsService.uploadDeliveryOrder(entityId, file, dates);
|
await bookingsService.uploadDeliveryOrder(entityId, files, dates);
|
||||||
} else {
|
} else {
|
||||||
await contractsService.uploadDeliveryOrder(entityId, file, dates);
|
await contractsService.uploadDeliveryOrder(entityId, files, dates);
|
||||||
}
|
}
|
||||||
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");
|
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");
|
||||||
} else {
|
} else {
|
||||||
const iso = vesselDate!.toISOString().slice(0, 10);
|
const iso = vesselDate!.toISOString().slice(0, 10);
|
||||||
const result = isBooking
|
const result = isBooking
|
||||||
? await bookingsService.uploadReleaseOrder(entityId, file, iso)
|
? await bookingsService.uploadReleaseOrder(entityId, files, iso)
|
||||||
: await contractsService.uploadReleaseOrder(entityId, file, iso);
|
: await contractsService.uploadReleaseOrder(entityId, files, iso);
|
||||||
if (result.hold) {
|
if (result.hold) {
|
||||||
toast.error(result.holdReason ?? "Vessel date too soon");
|
toast.error(result.holdReason ?? "Vessel date too soon");
|
||||||
} else {
|
} else {
|
||||||
toast.success(replaceMode ? "Release Order updated" : "Release Order uploaded");
|
toast.success(replaceMode ? "Release Order updated" : "Release Order uploaded");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setFile(null);
|
setFiles([]);
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
close();
|
close();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -152,14 +150,15 @@ export function GlClearanceUploadModal({
|
|||||||
<DoCollectionDateFields value={doDates} onChange={setDoDates} />
|
<DoCollectionDateFields value={doDates} onChange={setDoDates} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PhasedFileDropzone
|
<PhasedMultiFileDropzone
|
||||||
label={isDo ? "Delivery Order file" : "Release Order file"}
|
label={isDo ? "Delivery Order files" : "Release Order files"}
|
||||||
description={isDo ? "Any file type." : "PDF or image."}
|
description={
|
||||||
|
isDo ? "Any file type. Add as many files as needed." : "PDF or image. Add as many files as needed."
|
||||||
|
}
|
||||||
accept={isDo ? "*/*" : undefined}
|
accept={isDo ? "*/*" : undefined}
|
||||||
value={file}
|
value={files}
|
||||||
onChange={setFile}
|
onChange={setFiles}
|
||||||
replaceMode={replaceMode}
|
replaceMode={replaceMode}
|
||||||
onPreview={onPreview}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Group justify="flex-end" gap="sm">
|
<Group justify="flex-end" gap="sm">
|
||||||
@@ -170,7 +169,7 @@ export function GlClearanceUploadModal({
|
|||||||
color="edr-green"
|
color="edr-green"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={
|
disabled={
|
||||||
!file ||
|
files.length === 0 ||
|
||||||
(isRo && !vesselDate) ||
|
(isRo && !vesselDate) ||
|
||||||
(isDo && !doDatesComplete(doDates))
|
(isDo && !doDatesComplete(doDates))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,12 +34,15 @@ import {
|
|||||||
Truck,
|
Truck,
|
||||||
Upload,
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Freight } from "@edr/types";
|
import {
|
||||||
|
deliveryOrderFileLabel,
|
||||||
|
isDeliveryOrderFileCode,
|
||||||
|
type Freight,
|
||||||
|
} from "@edr/types";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||||
import { ExportClearanceStepper } from "@/components/contracts/ExportClearanceStepper";
|
import { ExportClearanceStepper } from "@/components/contracts/ExportClearanceStepper";
|
||||||
import { PhasedDocumentUploadField } from "@/components/contracts/PhasedDocumentUploadField";
|
|
||||||
import {
|
import {
|
||||||
DoCollectionDateFields,
|
DoCollectionDateFields,
|
||||||
doDatesComplete,
|
doDatesComplete,
|
||||||
@@ -2133,56 +2136,82 @@ function DeliveryOrderStep({
|
|||||||
onViewFile?: (file: { name: string; url: string }) => void;
|
onViewFile?: (file: { name: string; url: string }) => void;
|
||||||
onDownloadFile?: (file: { id: string; name: string }) => void;
|
onDownloadFile?: (file: { id: string; name: string }) => void;
|
||||||
}) {
|
}) {
|
||||||
const [files, setFiles] = useState<Record<string, File | null>>({
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
delivery_order: null,
|
|
||||||
});
|
|
||||||
const [doDates, setDoDates] = useDoCollectionDates({
|
const [doDates, setDoDates] = useDoCollectionDates({
|
||||||
vesselArrivalDate,
|
vesselArrivalDate,
|
||||||
doCollectedDate,
|
doCollectedDate,
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const hasFile = Boolean(files.delivery_order);
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (files.length === 0 || !doDatesComplete(doDates)) return;
|
||||||
|
const dates = {
|
||||||
|
vesselArrivalDate: toIsoDate(doDates.vesselArrival)!,
|
||||||
|
doCollectedDate: toIsoDate(doDates.doCollected)!,
|
||||||
|
};
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (isBooking) {
|
||||||
|
await bookingsService.uploadDeliveryOrder(entityId, files, dates);
|
||||||
|
} else {
|
||||||
|
await contractsService.uploadDeliveryOrder(entityId, files, dates);
|
||||||
|
}
|
||||||
|
setFiles([]);
|
||||||
|
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");
|
||||||
|
onChanged?.();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Upload failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PhasedDocumentUploadField
|
<Stack gap="sm">
|
||||||
fields={[{ key: "delivery_order", label: "Delivery Order" }]}
|
<Text size="sm" c="dimmed">
|
||||||
files={files}
|
Upload the Djibouti Delivery Order (DO) and record when the vessel arrived and when the
|
||||||
onChange={(key, file) => setFiles((prev) => ({ ...prev, [key]: file }))}
|
DO was collected. Add as many files as needed.
|
||||||
workflowFiles={workflowFiles}
|
</Text>
|
||||||
replaceMode={replaceMode}
|
|
||||||
loading={loading}
|
{workflowFiles
|
||||||
disabled={!hasFile || !doDatesComplete(doDates)}
|
.filter((wf) => isDeliveryOrderFileCode(wf.code) && wf.file)
|
||||||
helperText="Upload the Djibouti Delivery Order (DO) and record when the vessel arrived and when the DO was collected."
|
.map((wf, index) => (
|
||||||
submitLabel={replaceMode ? "Replace DO" : "Upload DO"}
|
<PhasedUploadedFileRow
|
||||||
extraFields={
|
key={wf.code}
|
||||||
<DoCollectionDateFields value={doDates} onChange={setDoDates} />
|
label={deliveryOrderFileLabel(wf.code, index)}
|
||||||
}
|
file={wf.file!}
|
||||||
onViewFile={onViewFile}
|
onView={onViewFile}
|
||||||
onDownloadFile={onDownloadFile}
|
onDownload={onDownloadFile}
|
||||||
onSubmit={async () => {
|
/>
|
||||||
const file = files.delivery_order;
|
))}
|
||||||
if (!file || !doDatesComplete(doDates)) return;
|
|
||||||
const dates = {
|
<DoCollectionDateFields value={doDates} onChange={setDoDates} />
|
||||||
vesselArrivalDate: toIsoDate(doDates.vesselArrival)!,
|
|
||||||
doCollectedDate: toIsoDate(doDates.doCollected)!,
|
<PhasedMultiFileDropzone
|
||||||
};
|
label="Delivery Order files"
|
||||||
setLoading(true);
|
description={
|
||||||
try {
|
replaceMode
|
||||||
if (isBooking) {
|
? "Replace the DO — upload one or more files (any file type)."
|
||||||
await bookingsService.uploadDeliveryOrder(entityId, file, dates);
|
: "Upload one or more Delivery Order files (any file type)."
|
||||||
} else {
|
|
||||||
await contractsService.uploadDeliveryOrder(entityId, file, dates);
|
|
||||||
}
|
|
||||||
setFiles({ delivery_order: null });
|
|
||||||
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");
|
|
||||||
onChanged?.();
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e instanceof Error ? e.message : "Upload failed");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
}}
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -184,13 +184,6 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
|||||||
subtitle: "Manage dropdown options used across the platform",
|
subtitle: "Manage dropdown options used across the platform",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
prefix: "/dashboard/audit-logs",
|
|
||||||
meta: {
|
|
||||||
title: "Audit Logs",
|
|
||||||
subtitle: "Request and entity-level activity recorded across the freight API",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
prefix: "/dashboard/configuration/contract-validity-periods",
|
prefix: "/dashboard/configuration/contract-validity-periods",
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@@ -517,7 +517,7 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
|
|||||||
label: "Audit logs",
|
label: "Audit logs",
|
||||||
href: "/dashboard/audit-logs",
|
href: "/dashboard/audit-logs",
|
||||||
icon: <History />,
|
icon: <History />,
|
||||||
permission: FREIGHT_PERMS.audit.view,
|
permission: FREIGHT_PERMS.auditLog.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Configuration",
|
label: "Configuration",
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
|
|||||||
const handleBuild = async () => {
|
const handleBuild = async () => {
|
||||||
if (!trainName.trim()) {
|
if (!trainName.trim()) {
|
||||||
toast({
|
toast({
|
||||||
title: "Enter the vogue number",
|
title: "Enter the voyage number",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -150,8 +150,8 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
|
|||||||
wagons are attached on the next screen.
|
wagons are attached on the next screen.
|
||||||
</Text>
|
</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Vogue number"
|
label="Voyage number"
|
||||||
placeholder="Enter vogue number"
|
placeholder="Enter voyage number"
|
||||||
value={trainName}
|
value={trainName}
|
||||||
onChange={(e) => setTrainName(e.currentTarget.value)}
|
onChange={(e) => setTrainName(e.currentTarget.value)}
|
||||||
maxLength={100}
|
maxLength={100}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { ArrowRight, Landmark, Package } from "lucide-react";
|
import { ArrowRight, Landmark, Package } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
|
Anchor,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
@@ -50,9 +52,20 @@ function EligibleBookingRow({
|
|||||||
<Stack gap={4} style={{ flex: 1 }}>
|
<Stack gap={4} style={{ flex: 1 }}>
|
||||||
<Group gap="xs" wrap="wrap">
|
<Group gap="xs" wrap="wrap">
|
||||||
<Package size={14} />
|
<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}
|
{booking.reference}
|
||||||
</Text>
|
</Anchor>
|
||||||
{resolvedFreightType ? (
|
{resolvedFreightType ? (
|
||||||
<Badge variant="outline" size="xs">
|
<Badge variant="outline" size="xs">
|
||||||
{resolvedFreightType}
|
{resolvedFreightType}
|
||||||
|
|||||||
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
Anchor,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Group,
|
Group,
|
||||||
@@ -7,7 +8,16 @@ import {
|
|||||||
Tabs,
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
} from "@mantine/core";
|
} 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";
|
import type { EligibleContainerBooking, FreightType } from "@/types/trainScheduling";
|
||||||
|
|
||||||
@@ -16,6 +26,8 @@ import { EligibleBookingsPanel } from "./EligibleBookingsPanel";
|
|||||||
export type AssignedBookingRow = {
|
export type AssignedBookingRow = {
|
||||||
id: string;
|
id: string;
|
||||||
reference: string;
|
reference: string;
|
||||||
|
/** Who booked it — staff scan this list by customer, not by reference. */
|
||||||
|
customer?: string | null;
|
||||||
weightTons?: number;
|
weightTons?: number;
|
||||||
isGovernment?: boolean;
|
isGovernment?: boolean;
|
||||||
wagonsRequired?: number | null;
|
wagonsRequired?: number | null;
|
||||||
@@ -90,9 +102,19 @@ export function ScheduleBookingsStep({
|
|||||||
>
|
>
|
||||||
<Stack gap={4}>
|
<Stack gap={4}>
|
||||||
<Group gap="xs">
|
<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}
|
{booking.reference}
|
||||||
</Text>
|
</Anchor>
|
||||||
{booking.weightTons != null ? (
|
{booking.weightTons != null ? (
|
||||||
<Badge variant="outline" size="xs" color="edr-green">
|
<Badge variant="outline" size="xs" color="edr-green">
|
||||||
{booking.weightTons}T
|
{booking.weightTons}T
|
||||||
@@ -119,6 +141,14 @@ export function ScheduleBookingsStep({
|
|||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
</Group>
|
</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 ? (
|
{booking.contractReference || booking.origin || booking.destination ? (
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
{booking.contractReference ? (
|
{booking.contractReference ? (
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -26,6 +26,8 @@ import { api } from "@/services/api";
|
|||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import type { Wagon } from "@/services/wagon.service";
|
import type { Wagon } from "@/services/wagon.service";
|
||||||
|
|
||||||
|
import WagonPicker from "./WagonPicker";
|
||||||
|
|
||||||
const stripHtml = (html: string) => html.replace(/<[^>]*>/g, "").trim();
|
const stripHtml = (html: string) => html.replace(/<[^>]*>/g, "").trim();
|
||||||
|
|
||||||
export interface WagonYardWorkspaceModalProps {
|
export interface WagonYardWorkspaceModalProps {
|
||||||
@@ -140,6 +142,8 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
|||||||
const [transferYardId, setTransferYardId] = useState<string | null>(null);
|
const [transferYardId, setTransferYardId] = useState<string | null>(null);
|
||||||
const [transferQty, setTransferQty] = useState(0);
|
const [transferQty, setTransferQty] = useState(0);
|
||||||
const [transferReason, setTransferReason] = useState("");
|
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(
|
const createRequest = useMutation(
|
||||||
api.wagonTransferRequests.create.mutationOptions(),
|
api.wagonTransferRequests.create.mutationOptions(),
|
||||||
@@ -240,8 +244,25 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
|||||||
setTransferYardId(null);
|
setTransferYardId(null);
|
||||||
setTransferQty(0);
|
setTransferQty(0);
|
||||||
setTransferReason("");
|
setTransferReason("");
|
||||||
|
setPickedWagons(new Set());
|
||||||
}, [yardId, typeId]);
|
}, [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.
|
// Reset the whole workspace when closed.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!opened) {
|
if (!opened) {
|
||||||
@@ -274,16 +295,23 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
|||||||
wagonTypeId: typeId,
|
wagonTypeId: typeId,
|
||||||
quantity: transferQty,
|
quantity: transferQty,
|
||||||
reason: transferReason,
|
reason: transferReason,
|
||||||
|
...(pickedWagons.size > 0
|
||||||
|
? { preferredWagonIds: [...pickedWagons] }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
toast({
|
toast({
|
||||||
title: `Requested ${transferQty} ${typeInfo.code(typeId)} wagon(s) · ${yardName(
|
title: `Requested ${transferQty} ${typeInfo.code(typeId)} wagon(s) · ${yardName(
|
||||||
yardId,
|
yardId,
|
||||||
)} → ${yardName(transferYardId)}`,
|
)} → ${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);
|
setTransferQty(0);
|
||||||
setTransferYardId(null);
|
setTransferYardId(null);
|
||||||
setTransferReason("");
|
setTransferReason("");
|
||||||
|
setPickedWagons(new Set());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(err, "Request failed");
|
showError(err, "Request failed");
|
||||||
}
|
}
|
||||||
@@ -438,10 +466,36 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
|||||||
ask for more than the yard can hand over. */}
|
ask for more than the yard can hand over. */}
|
||||||
<QuantityField
|
<QuantityField
|
||||||
value={transferQty}
|
value={transferQty}
|
||||||
onChange={setTransferQty}
|
onChange={handleQtyChange}
|
||||||
max={availableCount}
|
max={availableCount}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<Select
|
||||||
label="Destination yard"
|
label="Destination yard"
|
||||||
placeholder="Select destination"
|
placeholder="Select destination"
|
||||||
@@ -464,23 +518,42 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
|||||||
</div>
|
</div>
|
||||||
{transferYardId && transferQty > 0 ? (
|
{transferYardId && transferQty > 0 ? (
|
||||||
<Card bg="var(--mantine-color-gray-0)" radius="md" padding="sm" withBorder>
|
<Card bg="var(--mantine-color-gray-0)" radius="md" padding="sm" withBorder>
|
||||||
<Group gap={8} wrap="nowrap">
|
<Stack gap={6}>
|
||||||
<Text size="sm" fw={600}>
|
<Group gap={8} wrap="nowrap">
|
||||||
{yardName(yardId!)} {total}
|
<Text size="sm" fw={600}>
|
||||||
<Text span c="red.6" fw={700}>
|
{yardName(yardId!)} {total}
|
||||||
{" "}
|
<Text span c="red.6" fw={700}>
|
||||||
−{transferQty}
|
{" "}
|
||||||
|
−{transferQty}
|
||||||
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
<ArrowRight size={16} />
|
||||||
<ArrowRight size={16} />
|
<Text size="sm" fw={600}>
|
||||||
<Text size="sm" fw={600}>
|
{yardName(transferYardId)}
|
||||||
{yardName(transferYardId)}
|
<Text span c="teal.7" fw={700}>
|
||||||
<Text span c="teal.7" fw={700}>
|
{" "}
|
||||||
{" "}
|
+{transferQty}
|
||||||
+{transferQty}
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Group>
|
||||||
</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>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ export const URL_CONSTANTS = {
|
|||||||
BASE: "/exchange-settings",
|
BASE: "/exchange-settings",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
AUDIT_LOGS: {
|
||||||
|
BASE: "/audit",
|
||||||
|
},
|
||||||
|
|
||||||
DROPDOWN_SETTINGS: {
|
DROPDOWN_SETTINGS: {
|
||||||
BASE: "/dropdown-settings",
|
BASE: "/dropdown-settings",
|
||||||
BY_ID: (id: string) => `/api/dropdown-settings/${id}`,
|
BY_ID: (id: string) => `/api/dropdown-settings/${id}`,
|
||||||
@@ -321,10 +325,6 @@ export const URL_CONSTANTS = {
|
|||||||
SUMMARY: "/payments/summary",
|
SUMMARY: "/payments/summary",
|
||||||
},
|
},
|
||||||
|
|
||||||
AUDIT: {
|
|
||||||
LOGS: "/audit/logs",
|
|
||||||
},
|
|
||||||
|
|
||||||
LOCOMOTIVES: {
|
LOCOMOTIVES: {
|
||||||
BASE: "/locomotives",
|
BASE: "/locomotives",
|
||||||
BY_ID: (id: string) => `/locomotives/${id}`,
|
BY_ID: (id: string) => `/locomotives/${id}`,
|
||||||
@@ -359,6 +359,9 @@ export const URL_CONSTANTS = {
|
|||||||
`/train-scheduling/schedules/${id}/window-rule`,
|
`/train-scheduling/schedules/${id}/window-rule`,
|
||||||
SCHEDULE_DATE: (id: string) =>
|
SCHEDULE_DATE: (id: string) =>
|
||||||
`/train-scheduling/schedules/${id}/schedule-date`,
|
`/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) =>
|
CONTRACT_BOOKING_WINDOWS: (contractId: string) =>
|
||||||
`/train-scheduling/contracts/${contractId}/booking-windows`,
|
`/train-scheduling/contracts/${contractId}/booking-windows`,
|
||||||
MARK_BOOKING_PAID: (bookingId: string) =>
|
MARK_BOOKING_PAID: (bookingId: string) =>
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export const FREIGHT_PERMS = {
|
|||||||
dispatch: "edr_freight_app:train_scheduling:dispatch",
|
dispatch: "edr_freight_app:train_scheduling:dispatch",
|
||||||
markPaid: "edr_freight_app:train_scheduling:mark_paid",
|
markPaid: "edr_freight_app:train_scheduling:mark_paid",
|
||||||
expireBooking: "edr_freight_app:train_scheduling:expire_booking",
|
expireBooking: "edr_freight_app:train_scheduling:expire_booking",
|
||||||
|
editTrainNumber: "edr_freight_app:train_scheduling:edit_train_number",
|
||||||
},
|
},
|
||||||
fleet: {
|
fleet: {
|
||||||
view: "edr_freight_app:fleet:view",
|
view: "edr_freight_app:fleet:view",
|
||||||
@@ -188,6 +189,11 @@ export const FREIGHT_PERMS = {
|
|||||||
update: "edr_freight_app:trains:update",
|
update: "edr_freight_app:trains:update",
|
||||||
delete: "edr_freight_app:trains:delete",
|
delete: "edr_freight_app:trains:delete",
|
||||||
assignWagons: "edr_freight_app:trains:assign_wagons",
|
assignWagons: "edr_freight_app:trains:assign_wagons",
|
||||||
|
/** Train-builder detail Actions menu — each item its own grant. */
|
||||||
|
changeLocomotives: "edr_freight_app:trains:change_locomotives",
|
||||||
|
changeYard: "edr_freight_app:trains:change_yard",
|
||||||
|
toggleActive: "edr_freight_app:trains:toggle_active",
|
||||||
|
disband: "edr_freight_app:trains:disband",
|
||||||
},
|
},
|
||||||
routes: {
|
routes: {
|
||||||
view: "edr_freight_app:routes:view",
|
view: "edr_freight_app:routes:view",
|
||||||
@@ -306,6 +312,13 @@ export const FREIGHT_PERMS = {
|
|||||||
cancel: "edr_freight_app:warehouse_fee_invoices:cancel",
|
cancel: "edr_freight_app:warehouse_fee_invoices:cancel",
|
||||||
pay: "edr_freight_app:warehouse_fee_invoices:pay",
|
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: {
|
settings: {
|
||||||
fileUpload: {
|
fileUpload: {
|
||||||
view: "edr_freight_app:settings:file_upload:view",
|
view: "edr_freight_app:settings:file_upload:view",
|
||||||
@@ -343,9 +356,6 @@ export const FREIGHT_PERMS = {
|
|||||||
manage: "edr_freight_app:settings:support_content:manage",
|
manage: "edr_freight_app:settings:support_content:manage",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
audit: {
|
|
||||||
view: "edr_freight_app:audit:view",
|
|
||||||
},
|
|
||||||
staff: {
|
staff: {
|
||||||
roles: {
|
roles: {
|
||||||
view: "edr_freight_app:staff:roles:view",
|
view: "edr_freight_app:staff:roles:view",
|
||||||
|
|||||||
340
apps/edr-freight-web/backoffice/src/pages/AuditLogsPage.tsx
Normal file
340
apps/edr-freight-web/backoffice/src/pages/AuditLogsPage.tsx
Normal 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;
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
import { Badge, Box, Card, Stack, Text } from "@mantine/core";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from "@/components/page";
|
|
||||||
import { api } from "@/services/api";
|
|
||||||
import type {
|
|
||||||
AuditLogRow,
|
|
||||||
AuditQueryMethod,
|
|
||||||
AuditUser,
|
|
||||||
LocalizedText,
|
|
||||||
} from "@/services/audit.service";
|
|
||||||
import {
|
|
||||||
DataTable,
|
|
||||||
DataTableFooter,
|
|
||||||
usePagination,
|
|
||||||
type ColumnDef,
|
|
||||||
} from "@edr/ui-common";
|
|
||||||
|
|
||||||
const ACTION_LABELS: Record<AuditQueryMethod, string> = {
|
|
||||||
INSERT: "Created",
|
|
||||||
UPDATE: "Updated",
|
|
||||||
DELETE: "Deleted",
|
|
||||||
INSERT_CHILD: "Linked child",
|
|
||||||
DELETE_CHILD: "Unlinked child",
|
|
||||||
};
|
|
||||||
|
|
||||||
const ACTION_COLORS: Record<AuditQueryMethod, string> = {
|
|
||||||
INSERT: "edr-green",
|
|
||||||
UPDATE: "yellow",
|
|
||||||
DELETE: "red",
|
|
||||||
INSERT_CHILD: "indigo",
|
|
||||||
DELETE_CHILD: "gray",
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatDateTime(iso: string): string {
|
|
||||||
const d = new Date(iso);
|
|
||||||
return Number.isNaN(d.getTime())
|
|
||||||
? "—"
|
|
||||||
: d.toLocaleString(undefined, {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// See LocalizedText: `name`/`title` lifted from a raw audited entity can be
|
|
||||||
// a plain string or IAM's { am, en } — never render either directly.
|
|
||||||
// "undefined undefined" is the producer's own broken template when no user
|
|
||||||
// was attached at all (unauthenticated/customer flows, e.g. Fayda
|
|
||||||
// verification) — filtered out here rather than shown as raw garbage.
|
|
||||||
function localize(value: LocalizedText | null | undefined): string | undefined {
|
|
||||||
if (!value) return undefined;
|
|
||||||
if (typeof value === "object") return value.en ?? value.am ?? undefined;
|
|
||||||
if (/^undefined(\s+undefined)?$/.test(value.trim())) return undefined;
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatUser(user: AuditUser | null | undefined): string {
|
|
||||||
return localize(user?.name) ?? user?.id ?? "—";
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarize(row: AuditLogRow): string {
|
|
||||||
if (row.changes?.length) {
|
|
||||||
return row.changes
|
|
||||||
.slice(0, 2)
|
|
||||||
.map((c) => c.field)
|
|
||||||
.join(", ") + (row.changes.length > 2 ? `, +${row.changes.length - 2} more` : "");
|
|
||||||
}
|
|
||||||
if (row.payload) {
|
|
||||||
return (
|
|
||||||
localize(row.payload.name) ?? localize(row.payload.title) ?? row.payload.id ?? "—"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return "—";
|
|
||||||
}
|
|
||||||
|
|
||||||
const tableHeader =
|
|
||||||
"text-xs font-semibold uppercase tracking-wide text-muted-foreground";
|
|
||||||
|
|
||||||
export default function AuditLogsPage() {
|
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 20 });
|
|
||||||
|
|
||||||
const filter = {
|
|
||||||
skip: pagination.pageIndex * pagination.pageSize,
|
|
||||||
take: pagination.pageSize,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { data, isLoading, isError } = useQuery(
|
|
||||||
api.audit.list.queryOptions({ input: { filter } }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const rows = data?.items ?? [];
|
|
||||||
const total = data?.count ?? 0;
|
|
||||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
|
||||||
|
|
||||||
const columns: ColumnDef<AuditLogRow>[] = [
|
|
||||||
{
|
|
||||||
id: "time",
|
|
||||||
header: () => <span className={tableHeader}>Time</span>,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{formatDateTime(row.original.createdAt)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "action",
|
|
||||||
header: () => <span className={tableHeader}>Action</span>,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Badge
|
|
||||||
color={ACTION_COLORS[row.original.queryMethod] ?? "gray"}
|
|
||||||
variant="light"
|
|
||||||
radius="sm"
|
|
||||||
>
|
|
||||||
{ACTION_LABELS[row.original.queryMethod] ?? row.original.queryMethod}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "entity",
|
|
||||||
header: () => <span className={tableHeader}>Entity</span>,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="font-mono text-sm text-foreground">
|
|
||||||
{row.original.entityName}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "user",
|
|
||||||
header: () => <span className={tableHeader}>User</span>,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="text-sm text-foreground">
|
|
||||||
{formatUser(row.original.auditLog?.user)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "summary",
|
|
||||||
header: () => <span className={tableHeader}>Summary</span>,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="truncate text-sm text-muted-foreground">
|
|
||||||
{summarize(row.original)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContainer>
|
|
||||||
<PageHeader
|
|
||||||
title="Audit Logs"
|
|
||||||
subtitle="Request and entity-level activity recorded across the freight API."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Card p={0}>
|
|
||||||
<Stack gap={0}>
|
|
||||||
<Box px="md" pt="md" pb="sm" w="100%">
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{total} record{total !== 1 ? "s" : ""}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box style={{ overflowX: "auto" }} w="100%">
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={rows}
|
|
||||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
|
||||||
pagination={{
|
|
||||||
pageIndex: pagination.pageIndex,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
pageCount,
|
|
||||||
totalCount: total,
|
|
||||||
}}
|
|
||||||
tableOptions={{
|
|
||||||
state: { pagination },
|
|
||||||
onPaginationChange: setPagination,
|
|
||||||
manualPagination: true,
|
|
||||||
pageCount,
|
|
||||||
}}
|
|
||||||
containerClassName="border-0 shadow-none bg-transparent"
|
|
||||||
footer={DataTableFooter}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -247,7 +247,6 @@ export default function BookingRequestDetailPage() {
|
|||||||
<Box style={{ position: "sticky", top: 24 }}>
|
<Box style={{ position: "sticky", top: 24 }}>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<BookingCompanyCard booking={booking} />
|
<BookingCompanyCard booking={booking} />
|
||||||
<BookingSchedulingWindowCard booking={booking} />
|
|
||||||
<BookingPricingSummary booking={booking} />
|
<BookingPricingSummary booking={booking} />
|
||||||
<Box id="warehouse-payments">
|
<Box id="warehouse-payments">
|
||||||
<WarehouseInfoCard
|
<WarehouseInfoCard
|
||||||
@@ -257,6 +256,11 @@ export default function BookingRequestDetailPage() {
|
|||||||
tradeDirection={booking.tradeDirection}
|
tradeDirection={booking.tradeDirection}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</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
|
<BookingActionsToolbar
|
||||||
booking={booking}
|
booking={booking}
|
||||||
mutations={mutations}
|
mutations={mutations}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Progress,
|
Progress,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
|
Textarea,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { isAxiosError } from "axios";
|
import { isAxiosError } from "axios";
|
||||||
@@ -47,7 +48,7 @@ import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompo
|
|||||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import { canFleetAction, FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import type { TrainCompositionWagon } from "@/services/trainBuilder.service";
|
import type { TrainCompositionWagon } from "@/services/trainBuilder.service";
|
||||||
|
|
||||||
@@ -82,10 +83,19 @@ export default function TrainBuilderDetailPage() {
|
|||||||
const [deactivateOpen, setDeactivateOpen] = useState(false);
|
const [deactivateOpen, setDeactivateOpen] = useState(false);
|
||||||
const [maintenanceTarget, setMaintenanceTarget] =
|
const [maintenanceTarget, setMaintenanceTarget] =
|
||||||
useState<TrainCompositionWagon | null>(null);
|
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 { user } = useAuth();
|
||||||
const canUpdate = canFleetAction(user, "trains", "update");
|
|
||||||
const canDelete = canFleetAction(user, "trains", "delete");
|
|
||||||
const canAssign = hasPermission(user, FREIGHT_PERMS.trains.assignWagons);
|
const canAssign = hasPermission(user, FREIGHT_PERMS.trains.assignWagons);
|
||||||
|
const canChangeLocomotives = hasPermission(user, FREIGHT_PERMS.trains.changeLocomotives);
|
||||||
|
const canChangeYard = hasPermission(user, FREIGHT_PERMS.trains.changeYard);
|
||||||
|
const canToggleActive = hasPermission(user, FREIGHT_PERMS.trains.toggleActive);
|
||||||
|
const canDisband = hasPermission(user, FREIGHT_PERMS.trains.disband);
|
||||||
|
|
||||||
const compositionQuery = useQuery(
|
const compositionQuery = useQuery(
|
||||||
api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }),
|
api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }),
|
||||||
@@ -101,6 +111,19 @@ export default function TrainBuilderDetailPage() {
|
|||||||
const activate = useMutation(api.trainBuilder.activate.mutationOptions());
|
const activate = useMutation(api.trainBuilder.activate.mutationOptions());
|
||||||
|
|
||||||
const composition = compositionQuery.data;
|
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 =
|
const busy =
|
||||||
assignWagons.isPending ||
|
assignWagons.isPending ||
|
||||||
removeWagon.isPending ||
|
removeWagon.isPending ||
|
||||||
@@ -172,7 +195,7 @@ export default function TrainBuilderDetailPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
}
|
}
|
||||||
action={
|
action={
|
||||||
canUpdate || canDelete ? (
|
canChangeLocomotives || canChangeYard || canToggleActive || canDisband ? (
|
||||||
<Menu position="bottom-end" withinPortal shadow="md" width={220}>
|
<Menu position="bottom-end" withinPortal shadow="md" width={220}>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<Button variant="default" rightSection={<MoreHorizontal size={16} />}>
|
<Button variant="default" rightSection={<MoreHorizontal size={16} />}>
|
||||||
@@ -180,47 +203,49 @@ export default function TrainBuilderDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
{canUpdate ? (
|
{canChangeLocomotives ? (
|
||||||
<>
|
<Menu.Item
|
||||||
<Menu.Item
|
leftSection={<Replace size={15} />}
|
||||||
leftSection={<Replace size={15} />}
|
disabled={!composition.editable}
|
||||||
disabled={!composition.editable}
|
onClick={() => setLocoModalOpen(true)}
|
||||||
onClick={() => setLocoModalOpen(true)}
|
>
|
||||||
>
|
Change locomotives
|
||||||
Change locomotives
|
</Menu.Item>
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<MapPin size={15} />}
|
|
||||||
disabled={!composition.editable}
|
|
||||||
onClick={() => setYardModalOpen(true)}
|
|
||||||
>
|
|
||||||
Change yard
|
|
||||||
</Menu.Item>
|
|
||||||
{composition.status === "DEACTIVATED" ? (
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<Power size={15} />}
|
|
||||||
disabled={blockingLocomotives.length > 0}
|
|
||||||
onClick={() =>
|
|
||||||
void withToast(async () => {
|
|
||||||
await activate.mutateAsync(composition.id);
|
|
||||||
toast({ title: `Train ${composition.code} reactivated` });
|
|
||||||
}, "Could not reactivate train")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Reactivate train
|
|
||||||
</Menu.Item>
|
|
||||||
) : (
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<PowerOff size={15} />}
|
|
||||||
disabled={composition.activeSchedules.length > 0}
|
|
||||||
onClick={() => setDeactivateOpen(true)}
|
|
||||||
>
|
|
||||||
Deactivate train
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
{canDelete ? (
|
{canChangeYard ? (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<MapPin size={15} />}
|
||||||
|
disabled={!composition.editable}
|
||||||
|
onClick={() => setYardModalOpen(true)}
|
||||||
|
>
|
||||||
|
Change yard
|
||||||
|
</Menu.Item>
|
||||||
|
) : null}
|
||||||
|
{canToggleActive ? (
|
||||||
|
composition.status === "DEACTIVATED" ? (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<Power size={15} />}
|
||||||
|
disabled={blockingLocomotives.length > 0}
|
||||||
|
onClick={() =>
|
||||||
|
void withToast(async () => {
|
||||||
|
await activate.mutateAsync(composition.id);
|
||||||
|
toast({ title: `Train ${composition.code} reactivated` });
|
||||||
|
}, "Could not reactivate train")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Reactivate train
|
||||||
|
</Menu.Item>
|
||||||
|
) : (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<PowerOff size={15} />}
|
||||||
|
disabled={composition.activeSchedules.length > 0}
|
||||||
|
onClick={() => setDeactivateOpen(true)}
|
||||||
|
>
|
||||||
|
Deactivate train
|
||||||
|
</Menu.Item>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
{canDisband ? (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
color="red"
|
color="red"
|
||||||
leftSection={<Trash2 size={15} />}
|
leftSection={<Trash2 size={15} />}
|
||||||
@@ -277,7 +302,7 @@ export default function TrainBuilderDetailPage() {
|
|||||||
.
|
.
|
||||||
</Text>
|
</Text>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
{canUpdate ? (
|
{canChangeLocomotives ? (
|
||||||
<Button
|
<Button
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
variant="light"
|
variant="light"
|
||||||
@@ -459,7 +484,7 @@ export default function TrainBuilderDetailPage() {
|
|||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
opened={Boolean(maintenanceTarget)}
|
opened={Boolean(maintenanceTarget)}
|
||||||
onClose={() => setMaintenanceTarget(null)}
|
onClose={closeMaintenance}
|
||||||
title={<Text fw={600}>Send wagon to maintenance?</Text>}
|
title={<Text fw={600}>Send wagon to maintenance?</Text>}
|
||||||
radius="lg"
|
radius="lg"
|
||||||
centered
|
centered
|
||||||
@@ -472,14 +497,22 @@ export default function TrainBuilderDetailPage() {
|
|||||||
</Text>{" "}
|
</Text>{" "}
|
||||||
is detached from train{" "}
|
is detached from train{" "}
|
||||||
<Text span fw={700} c="dark">
|
<Text span fw={700} c="dark">
|
||||||
{composition.code}
|
{trainRunLabel}
|
||||||
</Text>{" "}
|
</Text>{" "}
|
||||||
and set to MAINTENANCE — it stays out of the available pool until it
|
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
|
clears. The detach is stamped with the time and this train's run
|
||||||
the wagon's history.
|
numbers in the wagon's history.
|
||||||
</Text>
|
</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">
|
<Group justify="flex-end">
|
||||||
<Button variant="default" onClick={() => setMaintenanceTarget(null)}>
|
<Button variant="default" onClick={closeMaintenance}>
|
||||||
Keep in consist
|
Keep in consist
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -491,11 +524,12 @@ export default function TrainBuilderDetailPage() {
|
|||||||
await maintenanceWagon.mutateAsync({
|
await maintenanceWagon.mutateAsync({
|
||||||
id: composition.id,
|
id: composition.id,
|
||||||
wagonId: maintenanceTarget!.id,
|
wagonId: maintenanceTarget!.id,
|
||||||
|
note: maintenanceNote.trim() || undefined,
|
||||||
});
|
});
|
||||||
toast({
|
toast({
|
||||||
title: `Wagon ${maintenanceTarget!.wagonNumber} sent to maintenance`,
|
title: `Wagon ${maintenanceTarget!.wagonNumber} sent to maintenance`,
|
||||||
});
|
});
|
||||||
setMaintenanceTarget(null);
|
closeMaintenance();
|
||||||
}, "Could not send wagon to maintenance")
|
}, "Could not send wagon to maintenance")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
CalendarClock,
|
CalendarClock,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
Clock,
|
||||||
|
Merge,
|
||||||
Container as ContainerIcon,
|
Container as ContainerIcon,
|
||||||
Eye,
|
Eye,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -54,6 +55,7 @@ import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPl
|
|||||||
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
||||||
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
|
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
|
||||||
import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel";
|
import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel";
|
||||||
|
import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal";
|
||||||
import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel";
|
import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel";
|
||||||
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
||||||
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
||||||
@@ -114,6 +116,7 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
|
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
|
||||||
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
|
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
|
||||||
const [windowSettingsOpen, setWindowSettingsOpen] = useState(false);
|
const [windowSettingsOpen, setWindowSettingsOpen] = useState(false);
|
||||||
|
const [mergeModalOpen, setMergeModalOpen] = useState(false);
|
||||||
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
|
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
|
||||||
const [gatepassReference, setGatepassReference] = useState("");
|
const [gatepassReference, setGatepassReference] = useState("");
|
||||||
const [gatepassFileUrl, setGatepassFileUrl] = useState("");
|
const [gatepassFileUrl, setGatepassFileUrl] = useState("");
|
||||||
@@ -400,7 +403,6 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
const canDispatch =
|
const canDispatch =
|
||||||
schedule.status === "SCHEDULED" &&
|
schedule.status === "SCHEDULED" &&
|
||||||
hasPermission(authUser, FREIGHT_PERMS.trainScheduling.dispatch);
|
hasPermission(authUser, FREIGHT_PERMS.trainScheduling.dispatch);
|
||||||
|
|
||||||
// Dispatch readiness: bookings with no wagon, and wagon-loaded bookings whose
|
// Dispatch readiness: bookings with no wagon, and wagon-loaded bookings whose
|
||||||
// cargo staff never marked loaded. Both are warnings, not blockers — staff can
|
// cargo staff never marked loaded. Both are warnings, not blockers — staff can
|
||||||
// still dispatch after confirming.
|
// still dispatch after confirming.
|
||||||
@@ -667,6 +669,7 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
assignedBookings={(schedule.bookings ?? []).map((b) => ({
|
assignedBookings={(schedule.bookings ?? []).map((b) => ({
|
||||||
id: b.id,
|
id: b.id,
|
||||||
reference: b.reference ?? b.id.slice(0, 8),
|
reference: b.reference ?? b.id.slice(0, 8),
|
||||||
|
customer: b.customer,
|
||||||
weightTons: b.weightTons,
|
weightTons: b.weightTons,
|
||||||
isGovernment: b.isGovernment,
|
isGovernment: b.isGovernment,
|
||||||
wagonsRequired: b.wagonsRequired,
|
wagonsRequired: b.wagonsRequired,
|
||||||
@@ -940,7 +943,7 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
{schedule.trainNumber ? (
|
{schedule.trainNumber ? (
|
||||||
<Box>
|
<Box>
|
||||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
|
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
|
||||||
Voyage No.
|
Train No.
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
ff="monospace"
|
ff="monospace"
|
||||||
@@ -952,6 +955,33 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : 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 ? (
|
{schedule.direction ? (
|
||||||
<Box>
|
<Box>
|
||||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
|
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
|
||||||
@@ -1369,6 +1399,15 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
onSaved={() => void detailQuery.refetch()}
|
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
|
<SwitchGovernmentBookingModal
|
||||||
key={switchTarget?.id ?? "none"}
|
key={switchTarget?.id ?? "none"}
|
||||||
opened={Boolean(switchTarget)}
|
opened={Boolean(switchTarget)}
|
||||||
|
|||||||
@@ -134,6 +134,9 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
// confirmation.
|
// confirmation.
|
||||||
const [dispatchTarget, setDispatchTarget] =
|
const [dispatchTarget, setDispatchTarget] =
|
||||||
useState<TrainScheduleListItem | null>(null);
|
useState<TrainScheduleListItem | null>(null);
|
||||||
|
// Cancelling is likewise irreversible — confirmed before the mutation fires.
|
||||||
|
const [cancelTarget, setCancelTarget] =
|
||||||
|
useState<TrainScheduleListItem | null>(null);
|
||||||
const [editDateSchedule, setEditDateSchedule] =
|
const [editDateSchedule, setEditDateSchedule] =
|
||||||
useState<TrainScheduleListItem | null>(null);
|
useState<TrainScheduleListItem | null>(null);
|
||||||
const [routeId, setRouteId] = useState("");
|
const [routeId, setRouteId] = useState("");
|
||||||
@@ -424,9 +427,7 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
<MetricChip value={row.original.bookingsCount} label="bkg" />
|
<MetricChip value={row.original.bookingsCount} label="bkg" />
|
||||||
{/* Wagon SLOTS this schedule's bookings occupy — not the coupled
|
<WagonChips schedule={row.original} />
|
||||||
consist. A built train shows 0 here until bookings are allocated. */}
|
|
||||||
<MetricChip value={row.original.wagonCount} label="wgn used" />
|
|
||||||
<MetricChip value={`${row.original.totalWeightTons}T`} label="" subtle />
|
<MetricChip value={`${row.original.totalWeightTons}T`} label="" subtle />
|
||||||
</Group>
|
</Group>
|
||||||
),
|
),
|
||||||
@@ -503,21 +504,7 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
<Menu.Item
|
<Menu.Item
|
||||||
color="red"
|
color="red"
|
||||||
leftSection={<Ban size={15} />}
|
leftSection={<Ban size={15} />}
|
||||||
onClick={async () => {
|
onClick={() => setCancelTarget(schedule)}
|
||||||
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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Cancel schedule
|
Cancel schedule
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
@@ -969,10 +956,118 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</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>
|
</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({
|
function MetricChip({
|
||||||
value,
|
value,
|
||||||
label,
|
label,
|
||||||
@@ -1078,7 +1173,7 @@ function ScheduleCard({
|
|||||||
</Group>
|
</Group>
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
<MetricChip value={schedule.bookingsCount} label="bkg" />
|
<MetricChip value={schedule.bookingsCount} label="bkg" />
|
||||||
<MetricChip value={schedule.wagonCount} label="wgn used" />
|
<WagonChips schedule={schedule} />
|
||||||
<MetricChip value={`${schedule.totalWeightTons}T`} label="" subtle />
|
<MetricChip value={`${schedule.totalWeightTons}T`} label="" subtle />
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -2,19 +2,18 @@ import { Freight } from "@edr/types";
|
|||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
Modal,
|
Modal,
|
||||||
ScrollArea,
|
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { AlertTriangle, ArrowRight, PackageCheck } from "lucide-react";
|
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 toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import WagonPicker from "@/components/wagons/WagonPicker";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { WagonTransferRequest } from "@/services/wagon.service";
|
import type { WagonTransferRequest } from "@/services/wagon.service";
|
||||||
|
|
||||||
@@ -59,23 +58,21 @@ export function TransferFulfillModal({
|
|||||||
|
|
||||||
const fulfill = useMutation(api.wagonTransferRequests.fulfill.mutationOptions());
|
const fulfill = useMutation(api.wagonTransferRequests.fulfill.mutationOptions());
|
||||||
|
|
||||||
const canTake = useMemo(
|
/** The requester's picks that are still in this yard and still available. */
|
||||||
() => Math.min(outstanding, wagons.length),
|
const preferredHere = useMemo(() => {
|
||||||
[outstanding, wagons.length],
|
const asked = new Set(request?.preferredWagonIds ?? []);
|
||||||
);
|
return asked.size ? wagons.filter((w) => asked.has(w.id)) : [];
|
||||||
|
}, [request, wagons]);
|
||||||
|
|
||||||
const toggle = (id: string) =>
|
const missingPreferred =
|
||||||
setPicked((prev) => {
|
(request?.preferredWagonIds?.length ?? 0) - preferredHere.length;
|
||||||
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 takeAllAvailable = () =>
|
// Open on what the requester asked for: OCC confirms rather than re-picks.
|
||||||
setPicked(new Set(wagons.slice(0, canTake).map((w) => w.id)));
|
// 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 = () => {
|
const close = () => {
|
||||||
setPicked(new Set());
|
setPicked(new Set());
|
||||||
@@ -120,27 +117,16 @@ export function TransferFulfillModal({
|
|||||||
>
|
>
|
||||||
{!request ? null : (
|
{!request ? null : (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Group justify="space-between" wrap="wrap">
|
<Text size="sm">
|
||||||
<Text size="sm">
|
<Text span fw={700}>
|
||||||
<Text span fw={700}>
|
{outstanding}
|
||||||
{outstanding}
|
</Text>{" "}
|
||||||
</Text>{" "}
|
wagon(s) still owed ·{" "}
|
||||||
wagon(s) still owed ·{" "}
|
<Text span fw={700}>
|
||||||
<Text span fw={700}>
|
{wagons.length}
|
||||||
{wagons.length}
|
</Text>{" "}
|
||||||
</Text>{" "}
|
available in {yardLabel(request.fromYard)}
|
||||||
available in {yardLabel(request.fromYard)}
|
</Text>
|
||||||
</Text>
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
size="xs"
|
|
||||||
radius="md"
|
|
||||||
disabled={canTake === 0}
|
|
||||||
onClick={takeAllAvailable}
|
|
||||||
>
|
|
||||||
Select {canTake}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{wagons.length < outstanding ? (
|
{wagons.length < outstanding ? (
|
||||||
<Alert color="yellow" radius="md" icon={<AlertTriangle size={15} />}>
|
<Alert color="yellow" radius="md" icon={<AlertTriangle size={15} />}>
|
||||||
@@ -150,27 +136,39 @@ export function TransferFulfillModal({
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : 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 ? (
|
{isLoading ? (
|
||||||
<Group justify="center" py="lg">
|
<Group justify="center" py="lg">
|
||||||
<Loader size="sm" />
|
<Loader size="sm" />
|
||||||
</Group>
|
</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}>
|
<WagonPicker
|
||||||
<Stack gap={4}>
|
wagons={wagons}
|
||||||
{wagons.map((w) => (
|
selected={picked}
|
||||||
<Checkbox
|
onChange={setPicked}
|
||||||
key={w.id}
|
// Never let staff pick more than is still owed — the API rejects it too.
|
||||||
checked={picked.has(w.id)}
|
max={outstanding}
|
||||||
onChange={() => toggle(w.id)}
|
emptyMessage="No available wagons of this type in the source yard right now."
|
||||||
label={w.wagonNumber}
|
maxHeight={300}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</ScrollArea.Autosize>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Group justify="flex-end" gap="sm">
|
<Group justify="flex-end" gap="sm">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { AlertTriangle, Send, XCircle } from "lucide-react";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import WagonPicker from "@/components/wagons/WagonPicker";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { WagonTransferRequest } from "@/services/wagon.service";
|
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
|
* 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
|
* 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,
|
enabled: boolean,
|
||||||
fromYardId: string | null,
|
fromYardId: string | null,
|
||||||
wagonTypeId: string | null,
|
wagonTypeId: string | null,
|
||||||
@@ -56,14 +59,15 @@ function useAvailableCount(
|
|||||||
...api.wagons.list.queryOptions({ input: {} }),
|
...api.wagons.list.queryOptions({ input: {} }),
|
||||||
enabled: enabled && Boolean(fromYardId && wagonTypeId),
|
enabled: enabled && Boolean(fromYardId && wagonTypeId),
|
||||||
});
|
});
|
||||||
if (!fromYardId || !wagonTypeId) return null;
|
if (!fromYardId || !wagonTypeId) return { wagons: [], count: null };
|
||||||
return wagons.filter(
|
const inYard = wagons.filter(
|
||||||
(w) =>
|
(w) =>
|
||||||
w.currentYardId === fromYardId &&
|
w.currentYardId === fromYardId &&
|
||||||
w.wagonTypeId === wagonTypeId &&
|
w.wagonTypeId === wagonTypeId &&
|
||||||
w.status === Freight.WagonStatus.Available &&
|
w.status === Freight.WagonStatus.Available &&
|
||||||
!w.trainId,
|
!w.trainId,
|
||||||
).length;
|
);
|
||||||
|
return { wagons: inYard, count: inYard.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TransferRequestFormModalProps {
|
export interface TransferRequestFormModalProps {
|
||||||
@@ -95,6 +99,8 @@ export function TransferRequestFormModal({
|
|||||||
const [wagonTypeId, setWagonTypeId] = useState<string | null>(null);
|
const [wagonTypeId, setWagonTypeId] = useState<string | null>(null);
|
||||||
const [quantity, setQuantity] = useState<number | string>(1);
|
const [quantity, setQuantity] = useState<number | string>(1);
|
||||||
const [reason, setReason] = useState("");
|
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.
|
// Re-seed on every open so a carry-over never leaks into the next request.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -104,11 +110,27 @@ export function TransferRequestFormModal({
|
|||||||
setWagonTypeId(prefillFrom?.wagonTypeId ?? null);
|
setWagonTypeId(prefillFrom?.wagonTypeId ?? null);
|
||||||
setQuantity(prefillFrom ? outstandingOn(prefillFrom) : 1);
|
setQuantity(prefillFrom ? outstandingOn(prefillFrom) : 1);
|
||||||
setReason(prefillFrom?.reason ?? "");
|
setReason(prefillFrom?.reason ?? "");
|
||||||
|
setPicked(new Set());
|
||||||
}, [opened, prefillFrom]);
|
}, [opened, prefillFrom]);
|
||||||
|
|
||||||
const create = useMutation(api.wagonTransferRequests.create.mutationOptions());
|
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)
|
// 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
|
// 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));
|
setQuantity((q) => (Number(q) > available ? available : q));
|
||||||
}, [available]);
|
}, [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 sameYard = Boolean(fromYardId && fromYardId === toYardId);
|
||||||
const overAvailable = available != null && Number(quantity) > available;
|
const overAvailable = available != null && Number(quantity) > available;
|
||||||
const valid =
|
const valid =
|
||||||
@@ -136,6 +168,7 @@ export function TransferRequestFormModal({
|
|||||||
wagonTypeId: wagonTypeId!,
|
wagonTypeId: wagonTypeId!,
|
||||||
quantity: Number(quantity),
|
quantity: Number(quantity),
|
||||||
reason: reason.trim(),
|
reason: reason.trim(),
|
||||||
|
...(picked.size > 0 ? { preferredWagonIds: [...picked] } : {}),
|
||||||
});
|
});
|
||||||
toast.success("Transfer request filed");
|
toast.success("Transfer request filed");
|
||||||
onClose();
|
onClose();
|
||||||
@@ -203,7 +236,7 @@ export function TransferRequestFormModal({
|
|||||||
clampBehavior={available == null ? "none" : "strict"}
|
clampBehavior={available == null ? "none" : "strict"}
|
||||||
allowNegative={false}
|
allowNegative={false}
|
||||||
value={quantity}
|
value={quantity}
|
||||||
onChange={setQuantity}
|
onChange={handleQuantityChange}
|
||||||
disabled={available === 0}
|
disabled={available === 0}
|
||||||
error={
|
error={
|
||||||
available === 0
|
available === 0
|
||||||
@@ -214,6 +247,34 @@ export function TransferRequestFormModal({
|
|||||||
}
|
}
|
||||||
required
|
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
|
<Textarea
|
||||||
label="Reason"
|
label="Reason"
|
||||||
placeholder="Why the wagons are needed"
|
placeholder="Why the wagons are needed"
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
TransferRequestFormModal,
|
TransferRequestFormModal,
|
||||||
} from "./TransferRequestModals";
|
} from "./TransferRequestModals";
|
||||||
import {
|
import {
|
||||||
|
PreferredWagonChips,
|
||||||
TransferProgress,
|
TransferProgress,
|
||||||
TransferStatusBadge,
|
TransferStatusBadge,
|
||||||
fmtDateTime,
|
fmtDateTime,
|
||||||
@@ -195,6 +196,11 @@ export default function WagonTransfersPage() {
|
|||||||
<Text size="sm">{wagonTypeLabel(row.original.wagonType)}</Text>
|
<Text size="sm">{wagonTypeLabel(row.original.wagonType)}</Text>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "wagons",
|
||||||
|
header: () => <span>Wagons requested</span>,
|
||||||
|
cell: ({ row }) => <PreferredWagonChips request={row.original} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "progress",
|
id: "progress",
|
||||||
header: () => <span>Delivered</span>,
|
header: () => <span>Delivered</span>,
|
||||||
@@ -564,6 +570,17 @@ export default function WagonTransfersPage() {
|
|||||||
{wagonTypeLabel(viewingReason.wagonType)} ·{" "}
|
{wagonTypeLabel(viewingReason.wagonType)} ·{" "}
|
||||||
{viewingReason.quantity} wagon(s)
|
{viewingReason.quantity} wagon(s)
|
||||||
</Text>
|
</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
|
<Box
|
||||||
className="text-sm [&_p]:my-2 [&_ol]:list-decimal [&_ul]:list-disc [&_ol]:pl-5 [&_ul]:pl-5"
|
className="text-sm [&_p]:my-2 [&_ol]:list-decimal [&_ul]:list-disc [&_ol]:pl-5 [&_ul]:pl-5"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
|
|||||||
@@ -3,6 +3,65 @@ import { Badge, Box, Group, Progress, Text, Tooltip } from "@mantine/core";
|
|||||||
|
|
||||||
import type { WagonTransferRequest } from "@/services/wagon.service";
|
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
|
/** 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
|
* gives a plain-text preview for list/table contexts (full formatting is
|
||||||
* shown via `sanitizeHtml` + `dangerouslySetInnerHTML` where there's room). */
|
* shown via `sanitizeHtml` + `dangerouslySetInnerHTML` where there's room). */
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ import type {
|
|||||||
PinWagonsPayload,
|
PinWagonsPayload,
|
||||||
RecordCheckpointPayload,
|
RecordCheckpointPayload,
|
||||||
StaffBookingWindow,
|
StaffBookingWindow,
|
||||||
|
ScheduleMergePreview,
|
||||||
TrainScheduleDetail,
|
TrainScheduleDetail,
|
||||||
TrainScheduleFilters,
|
TrainScheduleFilters,
|
||||||
TrainScheduleListFilters,
|
TrainScheduleListFilters,
|
||||||
@@ -166,11 +167,6 @@ import {
|
|||||||
type SaveLocomotivePayload,
|
type SaveLocomotivePayload,
|
||||||
} from "./locomotives.service";
|
} from "./locomotives.service";
|
||||||
import { overviewService } from "./overview.service";
|
import { overviewService } from "./overview.service";
|
||||||
import {
|
|
||||||
auditService,
|
|
||||||
type AuditLogListFilter,
|
|
||||||
type PaginatedAuditLogs,
|
|
||||||
} from "./audit.service";
|
|
||||||
import { reportsService } from "./reports.service";
|
import { reportsService } from "./reports.service";
|
||||||
import type { ReportQueryInput, ReportResult } from "@/types/reports";
|
import type { ReportQueryInput, ReportResult } from "@/types/reports";
|
||||||
import {
|
import {
|
||||||
@@ -601,6 +597,36 @@ export const api = {
|
|||||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
() => 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>(
|
markBookingPaid: endpoint<string, void>(
|
||||||
"train-scheduling",
|
"train-scheduling",
|
||||||
"mark-booking-paid",
|
"mark-booking-paid",
|
||||||
@@ -2045,11 +2071,14 @@ export const api = {
|
|||||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||||
),
|
),
|
||||||
|
|
||||||
sendWagonToMaintenance: endpoint<{ id: string; wagonId: string }, TrainComposition>(
|
sendWagonToMaintenance: endpoint<
|
||||||
|
{ id: string; wagonId: string; note?: string },
|
||||||
|
TrainComposition
|
||||||
|
>(
|
||||||
"train-builder",
|
"train-builder",
|
||||||
"sendWagonToMaintenance",
|
"sendWagonToMaintenance",
|
||||||
({ id, wagonId }) =>
|
({ id, wagonId, note }) =>
|
||||||
trainBuilderService.sendWagonToMaintenance(id, wagonId).then((r) => r.data),
|
trainBuilderService.sendWagonToMaintenance(id, wagonId, note).then((r) => r.data),
|
||||||
undefined,
|
undefined,
|
||||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||||
),
|
),
|
||||||
@@ -2153,15 +2182,6 @@ export const api = {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
audit: {
|
|
||||||
list: endpoint<{ filter?: AuditLogListFilter }, PaginatedAuditLogs>(
|
|
||||||
"audit",
|
|
||||||
"list",
|
|
||||||
({ filter }) => auditService.list(filter),
|
|
||||||
({ filter }) => ["audit", "list", filter ?? {}],
|
|
||||||
),
|
|
||||||
},
|
|
||||||
|
|
||||||
signatures: {
|
signatures: {
|
||||||
mySignature: endpoint<void, SavedSignature | null>(
|
mySignature: endpoint<void, SavedSignature | null>(
|
||||||
"me",
|
"me",
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
import { api as client } from "../auth/http";
|
|
||||||
import { unwrap } from "@/utils/endpoint";
|
|
||||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
|
||||||
|
|
||||||
const A = URL_CONSTANTS.AUDIT;
|
|
||||||
|
|
||||||
// Shape from @tria-plc/auditlog's AuditLogCommandController — see
|
|
||||||
// local-packages/FRONTEND_GUIDE.md.
|
|
||||||
export type AuditQueryMethod =
|
|
||||||
| "INSERT"
|
|
||||||
| "UPDATE"
|
|
||||||
| "DELETE"
|
|
||||||
| "INSERT_CHILD"
|
|
||||||
| "DELETE_CHILD";
|
|
||||||
|
|
||||||
export interface AuditFieldChange {
|
|
||||||
field: string;
|
|
||||||
from: unknown;
|
|
||||||
to: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
// IAM entities (users, orgs, positions, ...) name themselves bilingually —
|
|
||||||
// see edr-org.seeder.ts. Any `name`/`title` field lifted from a raw audited
|
|
||||||
// entity (auditLog.user, payload) can come back as either a plain string or
|
|
||||||
// this shape; both `name` fields below reflect that.
|
|
||||||
export type LocalizedText = string | { am?: string; en?: string };
|
|
||||||
|
|
||||||
// The vendored interceptor's own broken template produces a plain string
|
|
||||||
// ("undefined undefined") when no user was attached at all (unauthenticated/
|
|
||||||
// customer flows) — that's the non-bilingual string case for `name` here.
|
|
||||||
export interface AuditUser {
|
|
||||||
id?: string;
|
|
||||||
name?: LocalizedText;
|
|
||||||
organizationId?: string;
|
|
||||||
organizationName?: string;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuditLogRow {
|
|
||||||
id?: string;
|
|
||||||
createdAt: string;
|
|
||||||
deletedAt?: string | null;
|
|
||||||
entityName: string;
|
|
||||||
queryMethod: AuditQueryMethod;
|
|
||||||
changes?: AuditFieldChange[] | null;
|
|
||||||
payload?: { name?: LocalizedText; title?: LocalizedText; id?: string } | null;
|
|
||||||
auditLog?: { id?: string; user?: AuditUser | null };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuditLogListFilter {
|
|
||||||
skip?: number;
|
|
||||||
take?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginatedAuditLogs {
|
|
||||||
items: AuditLogRow[];
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const auditService = {
|
|
||||||
list: async (filter?: AuditLogListFilter): Promise<PaginatedAuditLogs> => {
|
|
||||||
const params: Record<string, number | undefined> = {
|
|
||||||
skip: filter?.skip,
|
|
||||||
take: filter?.take,
|
|
||||||
};
|
|
||||||
const response = await client.get<PaginatedAuditLogs>(A.LOGS, { params });
|
|
||||||
const data = unwrap(response.data) as PaginatedAuditLogs;
|
|
||||||
return { items: data.items ?? [], count: data.count ?? 0 };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -423,11 +423,11 @@ export const bookingsService = {
|
|||||||
|
|
||||||
uploadDeliveryOrder: async (
|
uploadDeliveryOrder: async (
|
||||||
id: string,
|
id: string,
|
||||||
file: File,
|
files: File[],
|
||||||
dates: { vesselArrivalDate: string; doCollectedDate: string },
|
dates: { vesselArrivalDate: string; doCollectedDate: string },
|
||||||
): Promise<BookingDetail> => {
|
): Promise<BookingDetail> => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", file);
|
files.forEach((file) => form.append("files", file));
|
||||||
form.append("vesselArrivalDate", dates.vesselArrivalDate);
|
form.append("vesselArrivalDate", dates.vesselArrivalDate);
|
||||||
form.append("doCollectedDate", dates.doCollectedDate);
|
form.append("doCollectedDate", dates.doCollectedDate);
|
||||||
const response = await client.post(B.CLEARANCE_DELIVERY_ORDER(id), form, {
|
const response = await client.post(B.CLEARANCE_DELIVERY_ORDER(id), form, {
|
||||||
@@ -438,11 +438,11 @@ export const bookingsService = {
|
|||||||
|
|
||||||
uploadReleaseOrder: async (
|
uploadReleaseOrder: async (
|
||||||
id: string,
|
id: string,
|
||||||
file: File,
|
files: File[],
|
||||||
vesselDepartureDate: string,
|
vesselDepartureDate: string,
|
||||||
): Promise<{ hold?: boolean; holdReason?: string }> => {
|
): Promise<{ hold?: boolean; holdReason?: string }> => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", file);
|
files.forEach((file) => form.append("files", file));
|
||||||
form.append("vesselDepartureDate", vesselDepartureDate);
|
form.append("vesselDepartureDate", vesselDepartureDate);
|
||||||
const response = await client.post(B.CLEARANCE_RELEASE_ORDER(id), form, {
|
const response = await client.post(B.CLEARANCE_RELEASE_ORDER(id), form, {
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
|||||||
@@ -471,11 +471,11 @@ export const contractsService = {
|
|||||||
|
|
||||||
uploadDeliveryOrder: async (
|
uploadDeliveryOrder: async (
|
||||||
id: string,
|
id: string,
|
||||||
file: File,
|
files: File[],
|
||||||
dates: { vesselArrivalDate: string; doCollectedDate: string },
|
dates: { vesselArrivalDate: string; doCollectedDate: string },
|
||||||
): Promise<Freight.IContract> => {
|
): Promise<Freight.IContract> => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", file);
|
files.forEach((file) => form.append("files", file));
|
||||||
form.append("vesselArrivalDate", dates.vesselArrivalDate);
|
form.append("vesselArrivalDate", dates.vesselArrivalDate);
|
||||||
form.append("doCollectedDate", dates.doCollectedDate);
|
form.append("doCollectedDate", dates.doCollectedDate);
|
||||||
const response = await client.post(C.CLEARANCE_DELIVERY_ORDER(id), form, {
|
const response = await client.post(C.CLEARANCE_DELIVERY_ORDER(id), form, {
|
||||||
@@ -486,11 +486,11 @@ export const contractsService = {
|
|||||||
|
|
||||||
uploadReleaseOrder: async (
|
uploadReleaseOrder: async (
|
||||||
id: string,
|
id: string,
|
||||||
file: File,
|
files: File[],
|
||||||
vesselDepartureDate: string,
|
vesselDepartureDate: string,
|
||||||
): Promise<{ contract: Freight.IContract; hold: boolean; holdReason?: string }> => {
|
): Promise<{ contract: Freight.IContract; hold: boolean; holdReason?: string }> => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", file);
|
files.forEach((file) => form.append("files", file));
|
||||||
form.append("vesselDepartureDate", vesselDepartureDate);
|
form.append("vesselDepartureDate", vesselDepartureDate);
|
||||||
const response = await client.post(C.CLEARANCE_RELEASE_ORDER(id), form, {
|
const response = await client.post(C.CLEARANCE_RELEASE_ORDER(id), form, {
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
|||||||
@@ -309,8 +309,11 @@ export const trainBuilderService = {
|
|||||||
removeWagon: (id: string, wagonId: string) =>
|
removeWagon: (id: string, wagonId: string) =>
|
||||||
apiClient.delete<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}`),
|
apiClient.delete<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}`),
|
||||||
/** Detach a wagon and move it to MAINTENANCE status. */
|
/** Detach a wagon and move it to MAINTENANCE status. */
|
||||||
sendWagonToMaintenance: (id: string, wagonId: string) =>
|
/** `note` is the maintenance reason — recorded with the train it came off. */
|
||||||
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/maintenance`),
|
sendWagonToMaintenance: (id: string, wagonId: string, note?: string) =>
|
||||||
|
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/maintenance`, {
|
||||||
|
note,
|
||||||
|
}),
|
||||||
reorderWagons: (id: string, wagonIds: string[]) =>
|
reorderWagons: (id: string, wagonIds: string[]) =>
|
||||||
apiClient.post<TrainComposition>(`${BASE}/${id}/reorder-wagons`, { wagonIds }),
|
apiClient.post<TrainComposition>(`${BASE}/${id}/reorder-wagons`, { wagonIds }),
|
||||||
/** Park the train indefinitely — only allowed with no active schedule. */
|
/** Park the train indefinitely — only allowed with no active schedule. */
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import type {
|
|||||||
PinWagonsPayload,
|
PinWagonsPayload,
|
||||||
RecordCheckpointPayload,
|
RecordCheckpointPayload,
|
||||||
StaffBookingWindow,
|
StaffBookingWindow,
|
||||||
|
ScheduleMergePreview,
|
||||||
TrainScheduleDetail,
|
TrainScheduleDetail,
|
||||||
UpdateScheduleWindowRulePayload,
|
UpdateScheduleWindowRulePayload,
|
||||||
TrainScheduleFilters,
|
TrainScheduleFilters,
|
||||||
@@ -294,6 +295,29 @@ export const trainSchedulingService = {
|
|||||||
return unwrap(response.data);
|
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> => {
|
markBookingPaid: async (bookingId: string): Promise<void> => {
|
||||||
await client.post(
|
await client.post(
|
||||||
URL_CONSTANTS.TRAIN_SCHEDULING.MARK_BOOKING_PAID(bookingId),
|
URL_CONSTANTS.TRAIN_SCHEDULING.MARK_BOOKING_PAID(bookingId),
|
||||||
|
|||||||
@@ -168,6 +168,10 @@ export interface WagonTransferRequest {
|
|||||||
closedShortByUserId?: string | null;
|
closedShortByUserId?: string | null;
|
||||||
/** Why the wagons are needed — required for new requests, shown on the queue. */
|
/** Why the wagons are needed — required for new requests, shown on the queue. */
|
||||||
reason?: string | null;
|
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;
|
note: string | null;
|
||||||
fromYard?: { id: string; label?: string; code?: string } | null;
|
fromYard?: { id: string; label?: string; code?: string } | null;
|
||||||
toYard?: { id: string; label?: string; code?: string } | null;
|
toYard?: { id: string; label?: string; code?: string } | null;
|
||||||
@@ -182,6 +186,8 @@ export interface CreateTransferRequestPayload {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
/** Mandatory: why the wagons are needed. */
|
/** Mandatory: why the wagons are needed. */
|
||||||
reason: string;
|
reason: string;
|
||||||
|
/** Specific wagons the requester wants — at most `quantity` of them. */
|
||||||
|
preferredWagonIds?: string[];
|
||||||
note?: string;
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -143,6 +143,12 @@ export interface BookingTrainScheduleSummary {
|
|||||||
actualArrivalAt: string | null;
|
actualArrivalAt: string | null;
|
||||||
windowPhase: string | null;
|
windowPhase: string | null;
|
||||||
paymentPhaseEndsAt: 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 {
|
export interface BookingDetail {
|
||||||
|
|||||||
@@ -217,7 +217,28 @@ export interface TrainScheduleListItem {
|
|||||||
name?: string | null;
|
name?: string | null;
|
||||||
currentYardId?: string | null;
|
currentYardId?: string | null;
|
||||||
}>;
|
}>;
|
||||||
|
/** Coupled consist size. NOT the used count — see `wagonsUsed`. */
|
||||||
wagonCount: number;
|
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;
|
totalWeightTons: number;
|
||||||
totalLengthMeters: number;
|
totalLengthMeters: number;
|
||||||
bookingsCount: number;
|
bookingsCount: number;
|
||||||
@@ -571,6 +592,35 @@ export interface UpdateScheduleWindowRulePayload {
|
|||||||
exportBookingLeadHours?: number;
|
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 {
|
export interface TrainScheduleDetail {
|
||||||
id: string;
|
id: string;
|
||||||
reference?: string | null;
|
reference?: string | null;
|
||||||
@@ -578,6 +628,8 @@ export interface TrainScheduleDetail {
|
|||||||
deferredBookings?: DeferredBookingRow[];
|
deferredBookings?: DeferredBookingRow[];
|
||||||
freightType?: FreightType | null;
|
freightType?: FreightType | null;
|
||||||
trainNumber?: string | 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). */
|
/** Wagon cap for this departure (built-train consist size or configured limit). */
|
||||||
maxWagons?: number | null;
|
maxWagons?: number | null;
|
||||||
/** Built train (Train Builder) behind this departure, when scheduled by train. */
|
/** Built train (Train Builder) behind this departure, when scheduled by train. */
|
||||||
@@ -621,6 +673,8 @@ export interface TrainScheduleDetail {
|
|||||||
trainSet?: {
|
trainSet?: {
|
||||||
id: string;
|
id: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
/** The built train this set runs on — null when it has none yet. */
|
||||||
|
trainId?: string | null;
|
||||||
wagonCount: number;
|
wagonCount: number;
|
||||||
totalWeightTons: number;
|
totalWeightTons: number;
|
||||||
totalLengthMeters: number;
|
totalLengthMeters: number;
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import { contractsService } from "@/services/contracts.service";
|
|||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { extractApiError } from "@/utils/result";
|
import { extractApiError } from "@/utils/result";
|
||||||
|
|
||||||
|
import "./contract-sign-bar.css";
|
||||||
|
|
||||||
const CONSENT_TEXT = "I have read the entire contract and agree to its terms.";
|
const CONSENT_TEXT = "I have read the entire contract and agree to its terms.";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -225,7 +227,7 @@ export default function ContractViewPage() {
|
|||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
p={{ base: "md", md: "xl" }}
|
p={{ base: "md", md: "xl" }}
|
||||||
pb={data.canSignCustomer ? 120 : undefined}
|
pb={data.canSignCustomer ? { base: 220, sm: 160, md: 120 } : undefined}
|
||||||
>
|
>
|
||||||
<Box maw={920} mx="auto">
|
<Box maw={920} mx="auto">
|
||||||
<Group justify="space-between" wrap="wrap" gap="sm" mb="md">
|
<Group justify="space-between" wrap="wrap" gap="sm" mb="md">
|
||||||
@@ -294,16 +296,19 @@ export default function ContractViewPage() {
|
|||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
left: 0,
|
left: "var(--sign-bar-left, 0px)",
|
||||||
right: 0,
|
right: 0,
|
||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
borderTop: "1px solid var(--mantine-color-gray-3)",
|
borderTop: "1px solid var(--mantine-color-gray-3)",
|
||||||
background: "var(--mantine-color-body)",
|
background: "var(--mantine-color-body)",
|
||||||
paddingBottom: 32,
|
paddingBottom: "max(env(safe-area-inset-bottom, 0px), 16px)",
|
||||||
|
maxHeight: "80vh",
|
||||||
|
overflowY: "auto",
|
||||||
}}
|
}}
|
||||||
|
className="contract-sign-bar"
|
||||||
>
|
>
|
||||||
<Box maw={920} mx="auto">
|
<Box maw={920} mx="auto">
|
||||||
<Stack gap="sm">
|
<Group justify="flex-start" align="flex-start" wrap="wrap" gap="sm">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={agreedToTerms}
|
checked={agreedToTerms}
|
||||||
onChange={(e) => setAgreedToTerms(e.currentTarget.checked)}
|
onChange={(e) => setAgreedToTerms(e.currentTarget.checked)}
|
||||||
@@ -315,17 +320,15 @@ export default function ContractViewPage() {
|
|||||||
: "Read the full contract above before you can agree and sign."
|
: "Read the full contract above before you can agree and sign."
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Group justify="flex-end">
|
<Button
|
||||||
<Button
|
color="edr-green"
|
||||||
color="edr-green"
|
leftSection={<FileSignature size={16} />}
|
||||||
leftSection={<FileSignature size={16} />}
|
disabled={!canProceedToSign}
|
||||||
disabled={!canProceedToSign}
|
onClick={openSign}
|
||||||
onClick={openSign}
|
>
|
||||||
>
|
{usingSaved ? "Approve & sign" : "Sign contract"}
|
||||||
{usingSaved ? "Approve & sign" : "Sign contract"}
|
</Button>
|
||||||
</Button>
|
</Group>
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ export default function NewShipmentRequestPage() {
|
|||||||
contract.contractKind === "GENERAL" &&
|
contract.contractKind === "GENERAL" &&
|
||||||
(contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled);
|
(contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled);
|
||||||
const isIntercity = contract.tradeDirection === "DOMESTIC";
|
const isIntercity = contract.tradeDirection === "DOMESTIC";
|
||||||
|
// Export shipments are invoiced in ETB only — USD is not offered.
|
||||||
|
const isExport = contract.tradeDirection === "EXPORT";
|
||||||
|
|
||||||
// Only the container sizes the contract was scoped for (20ft, 40ft, or both).
|
// Only the container sizes the contract was scoped for (20ft, 40ft, or both).
|
||||||
const SIZE_ORDER = ["20ft", "40ft"];
|
const SIZE_ORDER = ["20ft", "40ft"];
|
||||||
@@ -115,7 +117,7 @@ export default function NewShipmentRequestPage() {
|
|||||||
const dto: Freight.CreateBookingRequestDto = {
|
const dto: Freight.CreateBookingRequestDto = {
|
||||||
contractRouteId: route?.id,
|
contractRouteId: route?.id,
|
||||||
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
|
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
|
||||||
paymentCurrency: isIntercity ? "ETB" : paymentCurrency,
|
paymentCurrency: isIntercity || isExport ? "ETB" : paymentCurrency,
|
||||||
notes: notes.trim() || undefined,
|
notes: notes.trim() || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -246,16 +248,22 @@ export default function NewShipmentRequestPage() {
|
|||||||
<Text size="xs" c="dimmed" mb={8}>
|
<Text size="xs" c="dimmed" mb={8}>
|
||||||
{isIntercity
|
{isIntercity
|
||||||
? "Intercity shipments are invoiced in ETB."
|
? "Intercity shipments are invoiced in ETB."
|
||||||
: "Your contract is quoted in USD. Global Logistics will book this shipment and invoice you in the currency you pick here."}
|
: isExport
|
||||||
|
? "Export shipments are invoiced in ETB."
|
||||||
|
: "Your contract is quoted in USD. Global Logistics will book this shipment and invoice you in the currency you pick here."}
|
||||||
</Text>
|
</Text>
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={isIntercity ? "ETB" : paymentCurrency}
|
value={isIntercity || isExport ? "ETB" : paymentCurrency}
|
||||||
onChange={(v) => setPaymentCurrency(v as "USD" | "ETB")}
|
onChange={(v) => setPaymentCurrency(v as "USD" | "ETB")}
|
||||||
disabled={isIntercity}
|
disabled={isIntercity || isExport}
|
||||||
data={[
|
data={
|
||||||
{ label: "USD", value: "USD" },
|
isExport
|
||||||
{ label: "ETB", value: "ETB" },
|
? [{ label: "ETB", value: "ETB" }]
|
||||||
]}
|
: [
|
||||||
|
{ label: "USD", value: "USD" },
|
||||||
|
{ label: "ETB", value: "ETB" },
|
||||||
|
]
|
||||||
|
}
|
||||||
color="teal"
|
color="teal"
|
||||||
radius={10}
|
radius={10}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/* Keeps the fixed sign bar confined to the content area (right of the
|
||||||
|
navbar) instead of spanning the full viewport and drifting off-center. */
|
||||||
|
.contract-sign-bar {
|
||||||
|
--sign-bar-left: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 48em) {
|
||||||
|
.contract-sign-bar {
|
||||||
|
--sign-bar-left: 260px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ export default function ReportsPage() {
|
|||||||
const [dateRange, setDateRange] = useState('30');
|
const [dateRange, setDateRange] = useState('30');
|
||||||
const [startDate, setStartDate] = useState('');
|
const [startDate, setStartDate] = useState('');
|
||||||
const [endDate, setEndDate] = useState('');
|
const [endDate, setEndDate] = useState('');
|
||||||
|
const [routeOrigin, setRouteOrigin] = useState('');
|
||||||
|
const [routeDestination, setRouteDestination] = useState('');
|
||||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||||
const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('csv');
|
const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('csv');
|
||||||
|
|
||||||
@@ -78,6 +80,12 @@ export default function ReportsPage() {
|
|||||||
return r ? 1 / r.rate : null;
|
return r ? 1 / r.rate : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getBookingTicketCount = (booking: any): number => {
|
||||||
|
if (Array.isArray(booking.tickets)) return booking.tickets.length;
|
||||||
|
if (typeof booking.ticketCount === 'number') return booking.ticketCount;
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
const calcGrand = (rows: { currency: string; totalMinor: number }[]) =>
|
const calcGrand = (rows: { currency: string; totalMinor: number }[]) =>
|
||||||
rows.reduce((sum, { currency, totalMinor }) => {
|
rows.reduce((sum, { currency, totalMinor }) => {
|
||||||
const rate = toEtbRate(currency);
|
const rate = toEtbRate(currency);
|
||||||
@@ -107,6 +115,14 @@ export default function ReportsPage() {
|
|||||||
|
|
||||||
const confirmedBookings = bookings.filter((b: any) => b.status !== 'CANCELLED' && b.status !== 'REFUNDED');
|
const confirmedBookings = bookings.filter((b: any) => b.status !== 'CANCELLED' && b.status !== 'REFUNDED');
|
||||||
|
|
||||||
|
const totalRevenueMinor = confirmedBookings.reduce((sum, b: any) => {
|
||||||
|
const rate = toEtbRate(b.currency);
|
||||||
|
return rate !== null ? sum + Math.round((b.totalMinor || 0) * rate) : sum;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const totalBookingsCount = confirmedBookings.length;
|
||||||
|
const totalTicketsCount = confirmedBookings.reduce((sum, b: any) => sum + getBookingTicketCount(b), 0);
|
||||||
|
|
||||||
const byDate = confirmedBookings.reduce((acc: Record<string, any>, b: any) => {
|
const byDate = confirmedBookings.reduce((acc: Record<string, any>, b: any) => {
|
||||||
const date = new Date(b.createdAt).toISOString().split('T')[0];
|
const date = new Date(b.createdAt).toISOString().split('T')[0];
|
||||||
if (!acc[date]) acc[date] = { totalMinor: 0, count: 0 };
|
if (!acc[date]) acc[date] = { totalMinor: 0, count: 0 };
|
||||||
@@ -123,7 +139,50 @@ export default function ReportsPage() {
|
|||||||
bookings: d.count || 0,
|
bookings: d.count || 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const avgDailyRevenue = chartData.length > 0 ? Math.round(overallGrand / 100 / chartData.length) : 0;
|
const avgDailyRevenueMinor = chartData.length > 0 ? Math.round(totalRevenueMinor / chartData.length) : 0;
|
||||||
|
|
||||||
|
const totalRegularBookingsCount = confirmedBookings.filter((b: any) => {
|
||||||
|
const bookingType = String(b.bookingType || '').toUpperCase();
|
||||||
|
return bookingType !== 'PACKAGE' && !b.packageId;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
const totalPackageBookingsCount = confirmedBookings.filter((b: any) => {
|
||||||
|
const bookingType = String(b.bookingType || '').toUpperCase();
|
||||||
|
return bookingType === 'PACKAGE' || Boolean(b.packageId);
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
const filteredRouteBookings = confirmedBookings.filter((b: any) => {
|
||||||
|
const origin = b.schedule?.originStation?.name || b.originStationName || b.origin || 'Unknown';
|
||||||
|
const destination = b.schedule?.destinationStation?.name || b.destinationStationName || b.destination || 'Unknown';
|
||||||
|
if (routeOrigin && origin !== routeOrigin) return false;
|
||||||
|
if (routeDestination && destination !== routeDestination) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const routeOriginOptions = [
|
||||||
|
...new Set(confirmedBookings.map((b: any) => b.schedule?.originStation?.name || b.originStationName || b.origin || 'Unknown').filter(Boolean)),
|
||||||
|
].sort() as string[];
|
||||||
|
const routeDestinationOptions = [
|
||||||
|
...new Set(confirmedBookings.map((b: any) => b.schedule?.destinationStation?.name || b.destinationStationName || b.destination || 'Unknown').filter(Boolean)),
|
||||||
|
].sort() as string[];
|
||||||
|
|
||||||
|
const routeRevenueData = Object.entries(
|
||||||
|
filteredRouteBookings.reduce((acc: Record<string, { totalEtbMinor: number; bookings: number }>, b: any) => {
|
||||||
|
const origin = b.schedule?.originStation?.name || b.originStationName || b.origin || 'Unknown';
|
||||||
|
const destination = b.schedule?.destinationStation?.name || b.destinationStationName || b.destination || 'Unknown';
|
||||||
|
const route = `${origin} → ${destination}`;
|
||||||
|
const rate = toEtbRate(b.currency);
|
||||||
|
const etbMinor = rate !== null ? Math.round((b.totalMinor || 0) * rate) : 0;
|
||||||
|
if (!acc[route]) acc[route] = { totalEtbMinor: 0, bookings: 0 };
|
||||||
|
acc[route].totalEtbMinor += etbMinor;
|
||||||
|
acc[route].bookings += 1;
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
).map(([route, value]) => ({
|
||||||
|
route,
|
||||||
|
totalEtbMinor: value.totalEtbMinor,
|
||||||
|
bookings: value.bookings,
|
||||||
|
})).sort((a, b) => b.totalEtbMinor - a.totalEtbMinor);
|
||||||
|
|
||||||
const REPORT_COLS = ['Date', 'Revenue (ETB)', 'Confirmed Bookings'];
|
const REPORT_COLS = ['Date', 'Revenue (ETB)', 'Confirmed Bookings'];
|
||||||
|
|
||||||
@@ -161,6 +220,20 @@ export default function ReportsPage() {
|
|||||||
setExportModalOpen(false);
|
setExportModalOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const doExportRouteRevenue = () => {
|
||||||
|
if (!routeRevenueData.length) { alert('No route revenue to export'); return; }
|
||||||
|
const rows = routeRevenueData.map((row) => [row.route, String(row.bookings), formatCurrency(row.totalEtbMinor, 'ETB')]);
|
||||||
|
const headers = ['Route', 'Bookings', 'Revenue (ETB)'];
|
||||||
|
const csv = [headers.map((h) => `"${h}"`).join(','), ...rows.map((r) => r.map((v) => `"${v}"`).join(','))].join('\n');
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `route-revenue-${dates.startDate}-${dates.endDate}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
const renderCurrencyRow = ({ currency, totalMinor }: { currency: string; totalMinor: number }) => {
|
const renderCurrencyRow = ({ currency, totalMinor }: { currency: string; totalMinor: number }) => {
|
||||||
const rate = toEtbRate(currency);
|
const rate = toEtbRate(currency);
|
||||||
const etbMinor = rate !== null ? Math.round(totalMinor * rate) : null;
|
const etbMinor = rate !== null ? Math.round(totalMinor * rate) : null;
|
||||||
@@ -231,7 +304,7 @@ export default function ReportsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400 tabular-nums mt-1">
|
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400 tabular-nums mt-1">
|
||||||
{statsLoading ? '—' : formatCurrency(overallGrand, 'ETB')}
|
{isLoading ? '—' : formatCurrency(totalRevenueMinor, 'ETB')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col gap-1 border-t border-border pt-2 mt-1">
|
<div className="flex flex-col gap-1 border-t border-border pt-2 mt-1">
|
||||||
<div className="flex justify-between text-xs">
|
<div className="flex justify-between text-xs">
|
||||||
@@ -254,16 +327,16 @@ export default function ReportsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold tabular-nums mt-1">
|
<p className="text-2xl font-bold tabular-nums mt-1">
|
||||||
{statsLoading ? '—' : (stats?.totalBookings ?? 0).toLocaleString()}
|
{isLoading ? '—' : totalBookingsCount.toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col gap-1 border-t border-border pt-2 mt-1">
|
<div className="flex flex-col gap-1 border-t border-border pt-2 mt-1">
|
||||||
<div className="flex justify-between text-xs">
|
<div className="flex justify-between text-xs">
|
||||||
<span className="text-muted-foreground">Regular</span>
|
<span className="text-muted-foreground">Regular</span>
|
||||||
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalNormalBookings ?? 0).toLocaleString()}</span>
|
<span className="font-semibold tabular-nums">{isLoading ? '—' : totalRegularBookingsCount.toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-xs">
|
<div className="flex justify-between text-xs">
|
||||||
<span className="text-muted-foreground">Package</span>
|
<span className="text-muted-foreground">Package</span>
|
||||||
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalPackageBookings ?? 0).toLocaleString()}</span>
|
<span className="font-semibold tabular-nums">{isLoading ? '—' : totalPackageBookingsCount.toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -277,16 +350,16 @@ export default function ReportsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold tabular-nums mt-1">
|
<p className="text-2xl font-bold tabular-nums mt-1">
|
||||||
{statsLoading ? '—' : (stats?.totalTickets ?? 0).toLocaleString()}
|
{isLoading ? '—' : totalTicketsCount.toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col gap-1 border-t border-border pt-2 mt-1">
|
<div className="flex flex-col gap-1 border-t border-border pt-2 mt-1">
|
||||||
<div className="flex justify-between text-xs">
|
<div className="flex justify-between text-xs">
|
||||||
<span className="text-muted-foreground">Regular</span>
|
<span className="text-muted-foreground">Regular</span>
|
||||||
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalNormalTickets ?? 0).toLocaleString()}</span>
|
<span className="font-semibold tabular-nums">{isLoading ? '—' : totalRegularBookingsCount.toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-xs">
|
<div className="flex justify-between text-xs">
|
||||||
<span className="text-muted-foreground">Package</span>
|
<span className="text-muted-foreground">Package</span>
|
||||||
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalPackageTickets ?? 0).toLocaleString()}</span>
|
<span className="font-semibold tabular-nums">{isLoading ? '—' : totalPackageBookingsCount.toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -300,7 +373,7 @@ export default function ReportsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold tabular-nums mt-1">
|
<p className="text-2xl font-bold tabular-nums mt-1">
|
||||||
{isLoading ? '—' : formatCurrency(avgDailyRevenue * 100, 'ETB')}
|
{isLoading ? '—' : formatCurrency(avgDailyRevenueMinor, 'ETB')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground mt-auto pt-2 border-t border-border">
|
<p className="text-xs text-muted-foreground mt-auto pt-2 border-t border-border">
|
||||||
Over {chartData.length} active day{chartData.length !== 1 ? 's' : ''} in range
|
Over {chartData.length} active day{chartData.length !== 1 ? 's' : ''} in range
|
||||||
@@ -368,6 +441,85 @@ export default function ReportsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Route Revenue Breakdown */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
|
Revenue by Route
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Confirmed booking revenue for the selected date range, grouped by route.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>{routeRevenueData.length} route{routeRevenueData.length !== 1 ? 's' : ''}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-primary underline"
|
||||||
|
onClick={() => {
|
||||||
|
setRouteOrigin('');
|
||||||
|
setRouteDestination('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<label className="label">Origin</label>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={routeOrigin}
|
||||||
|
onChange={(e) => setRouteOrigin(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<option value="">All origins</option>
|
||||||
|
{routeOriginOptions.map((origin) => (
|
||||||
|
<option key={origin} value={origin}>{origin}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Destination</label>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={routeDestination}
|
||||||
|
onChange={(e) => setRouteDestination(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<option value="">All destinations</option>
|
||||||
|
{routeDestinationOptions.map((destination) => (
|
||||||
|
<option key={destination} value={destination}>{destination}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end justify-end">
|
||||||
|
<ActionButton variant="secondary" onClick={doExportRouteRevenue} disabled={isLoading || routeRevenueData.length === 0}>
|
||||||
|
Export route revenue
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{routeRevenueData.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No route revenue data available for this range.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{routeRevenueData.slice(0, 10).map((route) => (
|
||||||
|
<div key={route.route} className="grid grid-cols-1 md:grid-cols-[1.4fr_0.8fr_0.8fr] gap-3 items-center rounded-md bg-muted/20 p-3">
|
||||||
|
<div className="text-sm font-medium break-words">{route.route}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{route.bookings.toLocaleString()} booking{route.bookings !== 1 ? 's' : ''}</div>
|
||||||
|
<div className="text-right text-sm font-semibold tabular-nums">
|
||||||
|
{formatCurrency(route.totalEtbMinor, 'ETB')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Charts */}
|
{/* Charts */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Revenue Trend */}
|
{/* Revenue Trend */}
|
||||||
@@ -489,8 +641,8 @@ export default function ReportsPage() {
|
|||||||
{ label: 'Confirmed', value: bookings.filter((b: any) => b.status === 'CONFIRMED').length, fromStats: false },
|
{ label: 'Confirmed', value: bookings.filter((b: any) => b.status === 'CONFIRMED').length, fromStats: false },
|
||||||
{ label: 'Boarded', value: bookings.filter((b: any) => b.status === 'BOARDED').length, fromStats: false },
|
{ label: 'Boarded', value: bookings.filter((b: any) => b.status === 'BOARDED').length, fromStats: false },
|
||||||
{ label: 'Cancelled', value: bookings.filter((b: any) => b.status === 'CANCELLED').length, fromStats: false },
|
{ label: 'Cancelled', value: bookings.filter((b: any) => b.status === 'CANCELLED').length, fromStats: false },
|
||||||
{ label: 'Regular Bookings', value: stats?.totalNormalBookings ?? 0, fromStats: true },
|
{ label: 'Regular Bookings', value: totalRegularBookingsCount, fromStats: false },
|
||||||
{ label: 'Package Bookings', value: stats?.totalPackageBookings ?? 0, fromStats: true },
|
{ label: 'Package Bookings', value: totalPackageBookingsCount, fromStats: false },
|
||||||
].map(({ label, value, fromStats }) => (
|
].map(({ label, value, fromStats }) => (
|
||||||
<div key={label} className="border border-border rounded-lg p-3 text-center">
|
<div key={label} className="border border-border rounded-lg p-3 text-center">
|
||||||
<p className="text-xs text-muted-foreground">{label}</p>
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
|||||||
@@ -156,6 +156,48 @@ export function transitPermitFileLabel(code: string, index?: number): string {
|
|||||||
return code;
|
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). */
|
/** Legacy single T1 code (GL post-booking uploader). */
|
||||||
export const LEGACY_T1_TRANSPORT_CODE = "t1_transport_document";
|
export const LEGACY_T1_TRANSPORT_CODE = "t1_transport_document";
|
||||||
|
|
||||||
|
|||||||
33
pnpm-lock.yaml
generated
33
pnpm-lock.yaml
generated
@@ -99,9 +99,6 @@ importers:
|
|||||||
'@tria-plc/api-common':
|
'@tria-plc/api-common':
|
||||||
specifier: file:../../local-packages/tria-plc-api-common-1.6.0.tgz
|
specifier: file:../../local-packages/tria-plc-api-common-1.6.0.tgz
|
||||||
version: file:local-packages/tria-plc-api-common-1.6.0.tgz(ae9a56cc1c6d93629dd85f7605ccd5b6)
|
version: file:local-packages/tria-plc-api-common-1.6.0.tgz(ae9a56cc1c6d93629dd85f7605ccd5b6)
|
||||||
'@tria-plc/auditlog':
|
|
||||||
specifier: file:../../local-packages/tria-plc-auditlog-1.1.2.tgz
|
|
||||||
version: file:local-packages/tria-plc-auditlog-1.1.2.tgz(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/microservices@11.1.24)(@nestjs/swagger@11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2))(@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))))(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
|
|
||||||
'@tria-plc/iamapi-common':
|
'@tria-plc/iamapi-common':
|
||||||
specifier: file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz
|
specifier: file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz
|
||||||
version: file:local-packages/tria-plc-iamapi-common-1.0.0.tgz(d0be280d95adfc1b38e59bdc80c5dec5)
|
version: file:local-packages/tria-plc-iamapi-common-1.0.0.tgz(d0be280d95adfc1b38e59bdc80c5dec5)
|
||||||
@@ -5023,19 +5020,6 @@ packages:
|
|||||||
rxjs: ^7.8.0
|
rxjs: ^7.8.0
|
||||||
typeorm: ^0.3.0
|
typeorm: ^0.3.0
|
||||||
|
|
||||||
'@tria-plc/auditlog@file:local-packages/tria-plc-auditlog-1.1.2.tgz':
|
|
||||||
resolution: {integrity: sha512-3kRaAtETvM9wVRSbwd2jyrts9DCcSV9yFUDoEpxJfVQDsIGY2d9K0c+h1LAjfNNaogZ8eeBcroyCrnWjwBmkYA==, tarball: file:local-packages/tria-plc-auditlog-1.1.2.tgz}
|
|
||||||
version: 1.1.2
|
|
||||||
engines: {node: '>=18'}
|
|
||||||
peerDependencies:
|
|
||||||
'@nestjs/common': ^10.0.0 || ^11.0.0
|
|
||||||
'@nestjs/core': ^10.0.0 || ^11.0.0
|
|
||||||
'@nestjs/microservices': ^10.0.0 || ^11.0.0
|
|
||||||
'@nestjs/swagger': ^10.0.0 || ^11.0.0
|
|
||||||
'@nestjs/typeorm': ^10.0.0 || ^11.0.0
|
|
||||||
rxjs: ^7.0.0
|
|
||||||
typeorm: ^0.3.0
|
|
||||||
|
|
||||||
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-1.0.0.tgz':
|
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-1.0.0.tgz':
|
||||||
resolution: {integrity: sha512-rfHSXOm/0VUMTj7HrvYysrEAqxItqfPs4Rd35qWJyaAk+snF1wTKFRVz6cRGPoQNj8fhplkVAefnvuKBuHT+xQ==, tarball: file:local-packages/tria-plc-iamapi-common-1.0.0.tgz}
|
resolution: {integrity: sha512-rfHSXOm/0VUMTj7HrvYysrEAqxItqfPs4Rd35qWJyaAk+snF1wTKFRVz6cRGPoQNj8fhplkVAefnvuKBuHT+xQ==, tarball: file:local-packages/tria-plc-iamapi-common-1.0.0.tgz}
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
@@ -17899,18 +17883,6 @@ snapshots:
|
|||||||
- debug
|
- debug
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@tria-plc/auditlog@file:local-packages/tria-plc-auditlog-1.1.2.tgz(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/microservices@11.1.24)(@nestjs/swagger@11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2))(@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))))(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))':
|
|
||||||
dependencies:
|
|
||||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
|
||||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
|
||||||
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
|
||||||
'@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
|
||||||
'@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
|
|
||||||
amqp-connection-manager: 5.0.0(amqplib@0.10.9)
|
|
||||||
amqplib: 0.10.9
|
|
||||||
rxjs: 7.8.2
|
|
||||||
typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))
|
|
||||||
|
|
||||||
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-1.0.0.tgz(cc085a020c559b355f168432c579a024)':
|
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-1.0.0.tgz(cc085a020c559b355f168432c579a024)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
|
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
|
||||||
@@ -19010,11 +18982,6 @@ snapshots:
|
|||||||
amqplib: 0.10.9
|
amqplib: 0.10.9
|
||||||
promise-breaker: 6.0.0
|
promise-breaker: 6.0.0
|
||||||
|
|
||||||
amqp-connection-manager@5.0.0(amqplib@0.10.9):
|
|
||||||
dependencies:
|
|
||||||
amqplib: 0.10.9
|
|
||||||
promise-breaker: 6.0.0
|
|
||||||
|
|
||||||
amqp-connection-manager@5.0.0(amqplib@2.0.1):
|
amqp-connection-manager@5.0.0(amqplib@2.0.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
amqplib: 2.0.1
|
amqplib: 2.0.1
|
||||||
|
|||||||
Reference in New Issue
Block a user