Merge pull request #1258 from Tria-plc/dev

Merge dev to staging
This commit is contained in:
Abubeker Yasin
2026-08-12 14:14:30 +03:00
committed by GitHub
208 changed files with 15094 additions and 4952 deletions

View File

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

View File

@@ -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 12 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` |

View File

@@ -60,7 +60,6 @@
"@nestjs/typeorm": "^11.0.1",
"@nestjs/websockets": "^11.1.27",
"@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",
"amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1",

View File

@@ -16,7 +16,6 @@ import {
import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
import { IamModule } from "@tria-plc/iamapi-common";
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 databaseConfig from "./config/database.config";
@@ -49,6 +48,7 @@ import { SupportChatModule } from "./modules/support-chat/support-chat.module";
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module";
import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.module";
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
import { SupportContentModule } from "./modules/support-content/support-content.module";
import { OtpModule } from "./modules/otp/otp.module";
@@ -112,8 +112,7 @@ import { LastMileRequestsModule } from "./modules/last-mile-requests/last-mile-r
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
import { AiModule } from "./modules/ai/ai.module";
import { AuditModule } from "./modules/audit/audit.module";
import { LoggerMiddleware } from "./logger.middleware";
import { RequestLogMiddleware } from "@edr/api-common";
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache";
@@ -169,19 +168,6 @@ if (!process.env.APPLICATION_NAME) {
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,
IamModule.forRoot({
applications: [EDR_FREIGHT_APPLICATION],
@@ -221,6 +207,7 @@ if (!process.env.APPLICATION_NAME) {
FileUploadSettingsModule,
DropdownSettingsModule,
ExchangeSettingsModule,
StampSettingsModule,
ContractTemplatesModule,
SupportContentModule,
OtpModule,
@@ -257,7 +244,6 @@ if (!process.env.APPLICATION_NAME) {
EimsModule,
FleetHistoryModule,
AiModule,
AuditModule,
],
providers: [
EdrOrgSeeder,
@@ -390,7 +376,9 @@ export class AppModule implements OnApplicationBootstrap {
}
configure(consumer: MiddlewareConsumer) {
consumer.apply(LoggerMiddleware).forRoutes("*");
// FIRST: opens the request log context every later middleware/guard/service
// writes into via logCtx(). Anything applied above it logs into the void.
consumer.apply(RequestLogMiddleware).forRoutes("*");
consumer
.apply(LoginAudienceMiddleware)
.forRoutes(

View File

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

View File

@@ -0,0 +1,146 @@
import { Logger } from "@nestjs/common";
import type { Repository } from "typeorm";
import {
BaseRepository,
RequestLogMiddleware,
getLogContext,
logCtx,
runWithLogContext,
} from "@edr/api-common";
describe("logCtx", () => {
it("is a no-op outside a request", () => {
expect(() => logCtx({ bookingId: "b1" })).not.toThrow();
expect(getLogContext()).toBeUndefined();
});
it("collects data points across the request and isolates concurrent ones", async () => {
const collect = async (id: string) =>
runWithLogContext({ requestId: id }, async () => {
logCtx({ bookingId: id });
await Promise.resolve();
logCtx({ from: "DRAFT", to: "SUBMITTED" }, { path: "booking.status" });
logCtx({ wagonId: "w1" }, { path: "wagons", mode: "push" });
logCtx({ wagonId: "w2" }, { path: "wagons", mode: "push" });
logCtx(1, { path: "smsSent", mode: "count" });
logCtx(1, { path: "smsSent", mode: "count" });
logCtx("PAID", { path: "payment.state", mode: "set" });
logCtx({ ignored: true }, (ctx) => {
ctx.custom = "yes";
});
return getLogContext();
});
const [a, b] = await Promise.all([collect("r1"), collect("r2")]);
expect(a).toEqual({
requestId: "r1",
bookingId: "r1",
booking: { status: { from: "DRAFT", to: "SUBMITTED" } },
wagons: [{ wagonId: "w1" }, { wagonId: "w2" }],
smsSent: 2,
payment: { state: "PAID" },
custom: "yes",
});
expect(b?.requestId).toBe("r2");
expect(b?.bookingId).toBe("r2");
});
});
describe("RequestLogMiddleware", () => {
it("emits one canonical JSON line carrying the collected context", () => {
// Raw stdout, not the Nest logger — the line must be parsable JSON with no
// "[Nest] … LOG [request]" prefix in front of it.
const lines: string[] = [];
jest.spyOn(process.stdout, "write").mockImplementation((chunk) => {
lines.push(String(chunk));
return true;
});
jest.spyOn(Logger.prototype, "log").mockImplementation(() => undefined);
const listeners: Record<string, () => void> = {};
const req = {
method: "POST",
url: "/api/bookings/1/submit",
originalUrl: "/api/bookings/1/submit?dry=1",
baseUrl: "/api/bookings",
route: { path: "/:id/submit" },
headers: { "user-agent": "jest", "x-request-id": "req-42" },
ip: "10.0.0.1",
query: { dry: "1" },
user: { id: "u-7" },
};
const res = {
statusCode: 409,
writableEnded: true,
setHeader: jest.fn(),
on: (event: string, fn: () => void) => {
listeners[event] = fn;
},
};
new RequestLogMiddleware().use(req, res, () => {
logCtx({ bookingId: "b-1" });
logCtx("REJECTED", { path: "booking.outcome", mode: "set" });
});
listeners.finish();
listeners.close(); // aborts/close after finish must not double-log
expect(lines).toHaveLength(1);
expect(lines[0].endsWith("\n")).toBe(true);
expect(lines[0].startsWith("{")).toBe(true);
expect(JSON.parse(lines[0])).toMatchObject({
level: "warn",
logger: "request",
type: "http_request",
requestId: "req-42",
method: "POST",
route: "/api/bookings/:id/submit",
url: "/api/bookings/1/submit?dry=1",
status: 409,
userId: "u-7",
ip: "10.0.0.1",
userAgent: "jest",
query: { dry: "1" },
bookingId: "b-1",
booking: { outcome: "REJECTED" },
});
expect(res.setHeader).toHaveBeenCalledWith("x-request-id", "req-42");
jest.restoreAllMocks();
});
});
describe("BaseRepository write trail", () => {
class TestRepo extends BaseRepository<{ id: string; status?: string }> {
constructor(repo: Repository<{ id: string; status?: string }>) {
super(repo);
}
}
const typeormRepo = {
metadata: { tableName: "booking" },
create: (data: unknown) => data,
save: async (data: unknown) => data,
update: async () => undefined,
findOne: async () => ({ id: "b-1", status: "SUBMITTED" }),
softDelete: async () => undefined,
delete: async () => undefined,
} as unknown as Repository<{ id: string; status?: string }>;
it("records creates, status changes and deletes without any service opting in", async () => {
const ctx = await runWithLogContext({}, async () => {
const repo = new TestRepo(typeormRepo);
await repo.create({ id: "b-1" });
await repo.update("b-1", { status: "SUBMITTED" });
await repo.update("b-1", { id: "b-1" }); // no status → no transition entry
await repo.softDelete("b-1");
return getLogContext();
});
expect(ctx).toEqual({
db: { created: { booking: 1 }, updated: { booking: 2 } },
statusChanges: [{ entity: "booking", id: "b-1", status: "SUBMITTED" }],
deleted: [{ entity: "booking", id: "b-1" }],
});
});
});

View File

@@ -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 { 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 { 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 = [
UnitSetting,
@@ -185,7 +180,6 @@ export function buildDataSourceOptions(): DataSourceOptions {
entities: [
__dirname + "/../**/*.entity.{ts,js}",
...iamEntities,
...auditEntities,
],
migrations: [],
};

View File

@@ -0,0 +1,99 @@
import { ContractViewModelBuilder, ContractSignatureView } from "./contract-view-model.builder";
/**
* The EDR side of a contract is sealed with the ONE global company stamp, read
* live at render time; the client side keeps whatever stamp the customer
* uploaded. These specs pin that asymmetry — the standing rule is that
* centralizing the EDR seal must not touch customer stamps.
*/
describe("ContractViewModelBuilder.attachProviderStamp", () => {
const STAMP = "data:image/png;base64,RURS";
const build = (stampImageUrl: string | null = STAMP) => {
const getStampImageUrl = jest.fn().mockResolvedValue(stampImageUrl);
const builder = Object.create(
ContractViewModelBuilder.prototype,
) as ContractViewModelBuilder;
Object.assign(builder, { stampSettings: { getStampImageUrl } });
return { builder, getStampImageUrl };
};
const sig = (role: "STAFF" | "CUSTOMER", extra: Partial<ContractSignatureView> = {}) =>
({
role,
signerDisplayName: `${role} signer`,
signedAt: "1 January 2026",
signatureImageUrl: "https://minio.local/sig.png",
...extra,
}) as ContractSignatureView;
it("stamps the EDR side with the global stamp", async () => {
const { builder } = build();
const signatures = [sig("STAFF")];
await builder.attachProviderStamp(signatures);
expect(signatures[0]!.stampImageUrl).toBe(STAMP);
});
it("leaves the customer side untouched", async () => {
const { builder } = build();
const customerStamp = "data:image/png;base64,Q1VTVA==";
const signatures = [
sig("CUSTOMER", { stampImageUrl: customerStamp }),
sig("STAFF"),
];
await builder.attachProviderStamp(signatures);
expect(signatures[0]!.stampImageUrl).toBe(customerStamp);
expect(signatures[1]!.stampImageUrl).toBe(STAMP);
});
it("does not read the stamp at all when EDR has not signed yet", async () => {
const { builder, getStampImageUrl } = build();
const signatures = [sig("CUSTOMER")];
await builder.attachProviderStamp(signatures);
expect(getStampImageUrl).not.toHaveBeenCalled();
expect(signatures[0]!.stampImageUrl).toBeUndefined();
});
it("renders unstamped rather than failing when no stamp is configured", async () => {
const { builder } = build(null);
const signatures = [sig("STAFF")];
await expect(builder.attachProviderStamp(signatures)).resolves.toBeUndefined();
expect(signatures[0]!.stampImageUrl).toBeNull();
});
it("reads the stamp once for every EDR signature row", async () => {
const { builder, getStampImageUrl } = build();
const signatures = [sig("STAFF"), sig("STAFF")];
await builder.attachProviderStamp(signatures);
expect(getStampImageUrl).toHaveBeenCalledTimes(1);
expect(signatures.map((s) => s.stampImageUrl)).toEqual([STAMP, STAMP]);
});
it("is applied by loadSignatures, so the HTML view and the PDF agree", async () => {
const { builder } = build();
Object.assign(builder, {
bookingsRepository: {
findContractSignatures: jest.fn().mockResolvedValue([
{ signerRole: "STAFF", signerDisplayName: "EDR", signedAt: new Date() },
]),
},
});
const views = await (
builder as unknown as {
loadSignatures(id: string): Promise<ContractSignatureView[]>;
}
).loadSignatures("b-1");
expect(views[0]!.stampImageUrl).toBe(STAMP);
});
});

View File

@@ -9,6 +9,7 @@ import {
import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pricing-schedule.builder';
import { ContractRateScheduleBuilder, RateSchedule } from './contract-rate-schedule.builder';
import { ContractTemplateResolver } from './contract-template.resolver';
import { StampSettingsService } from '../modules/stamp-settings/stamp-settings.service';
import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry';
export interface ContractSignatureView {
@@ -16,6 +17,11 @@ export interface ContractSignatureView {
signerDisplayName: string;
signedAt: string;
signatureImageUrl?: string | null;
/**
* Round company seal shown beside the signature. Populated for the EDR
* (STAFF) side only, from the single global stamp — see attachProviderStamp.
*/
stampImageUrl?: string | null;
}
/**
@@ -114,6 +120,7 @@ export class ContractViewModelBuilder {
private readonly templateResolver: ContractTemplateResolver,
private readonly pricingBuilder: ContractPricingScheduleBuilder,
private readonly rateScheduleBuilder: ContractRateScheduleBuilder,
private readonly stampSettings: StampSettingsService,
) {}
async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> {
@@ -194,7 +201,30 @@ export class ContractViewModelBuilder {
private async loadSignatures(bookingId: string): Promise<ContractSignatureView[]> {
const rows = await this.bookingsRepository.findContractSignatures(bookingId);
return rows.map((s) => this.toSignatureView(s));
const views = rows.map((s) => this.toSignatureView(s));
await this.attachProviderStamp(views);
return views;
}
/**
* Stamp the EDR side of the contract with the ONE global company stamp
* (StampSettingsService) — staff never upload or pick a stamp, so nothing is
* stored per signature and the seal is read live at render time. The client
* side is left alone: a customer's own stamp is their business.
*
* Read live and deliberately not snapshotted, so replacing the company stamp
* re-seals contracts on their next render. `getStampImageUrl()` never throws
* and returns a data URL, which `signatures_block.hbs` renders as-is and the
* signature inliner skips.
*/
async attachProviderStamp(signatures: ContractSignatureView[]): Promise<void> {
const staff = signatures.filter((s) => s.role === 'STAFF');
if (staff.length === 0) return;
const stampImageUrl = await this.stampSettings.getStampImageUrl();
for (const sig of staff) {
sig.stampImageUrl = stampImageUrl;
}
}
toSignatureView(row: BookingContractSignature): ContractSignatureView {

View File

@@ -1,21 +0,0 @@
import { Injectable, NestMiddleware, Logger } from "@nestjs/common";
import { Request, Response, NextFunction } from "express";
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
private readonly logger = new Logger("HTTP");
use(req: Request, res: Response, next: NextFunction) {
const start = Date.now();
res.on("finish", () => {
const duration = Date.now() - start;
this.logger.log(
`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`,
);
});
next();
}
}

View File

@@ -10,15 +10,17 @@ import {
ResponseTransformInterceptor,
createValidationPipe,
} from "@edr/api-common";
import { getAuditLoggerConfig } from "@tria-plc/auditlog";
import { AppModule } from "./app.module";
/**
* JSON body ceiling. Signing posts the signature AND the company stamp as
* base64 in one JSON body, and base64 inflates bytes by ~4/3 — a 50MB asset is
* ~67MB on the wire. Express defaults to 100kb, which rejected any real stamp
* image with a 413 "request entity too large".
* JSON body ceiling. Customer signing posts the signature AND the customer's
* own company stamp as base64 in one JSON body, and base64 inflates bytes by
* ~4/3 — a 50MB asset is ~67MB on the wire. Express defaults to 100kb, which
* rejected any real stamp image with a 413 "request entity too large".
* (Staff signing posts only a signature: EDR's seal is the one global stamp,
* read server-side. Uploading that stamp under Settings goes through this same
* ceiling, so the headroom is still needed on both counts.)
*
* Sized to clear the 50MB per-document ceiling
* (`DOCUMENT_UPLOAD_MAX_BYTES`) after base64 inflation, with room for the
@@ -167,11 +169,6 @@ export async function createFreightApp(): Promise<NestExpressApplication> {
app.useGlobalFilters(new HttpExceptionFilter());
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();
const config = new DocumentBuilder()

View File

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

View File

@@ -0,0 +1,210 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Onboarding revamp: one company, one verified identity.
*
* The general manager is removed outright — it named who to talk to and gated
* nothing — and the company's people become its **owner** (whoever the eTrade
* licence names as the business's manager) and its **Power of Attorney**.
* Exactly one of them is identity-verified, chosen by the company's own answer
* to "does anyone hold power of attorney for you?", stored as
* `attributes.poaDeclared`.
*
* The order below matters — each step depends on data a later step destroys:
*
* 1. Rescue notification addresses. `companyNotifyEmailExpr` used to fall
* through to `attributes->>'generalManagerEmail'`, and `companies.email` was
* only ever written for a Fayda-verified owner — so every foreign company
* had none and was reached solely through that fallback. Promote it to the
* column before the key is stripped, or those companies stop receiving mail
* in silence.
* 2. Backfill the owner. `ownerName`/`ownerEmail`/`ownerPhone` are now required
* onboarding fields; without this every already-onboarded company would
* report three missing fields the moment it opened its settings page.
* 3. Resolve `poaSameAsOwner`. That flag waived the DARS delegation paper. It
* is gone, so the companies holding it must be re-expressed:
* - NOT a freight forwarder → "no PoA" (the owner represents themselves,
* nothing to delegate). Their PoA details are cleared.
* - A freight forwarder → "yes" and details KEPT. A forwarder signs on
* other companies' behalf, so a representative is non-negotiable and the
* waiver no longer exists. These companies will be asked for a
* delegation paper they were previously excused — an intentional,
* visible consequence, not an oversight. Count them before deploying.
* 4. Derive the declaration for everyone else, from whether PoA details exist.
* 5. Move drafts off the deleted "personnel" wizard step.
* 6. Only now drop the columns and strip the retired attribute keys.
*
* Irreversible by design: `down()` restores the columns' shape but cannot
* recover the values, and re-deriving `poaSameAsOwner` from `poaDeclared` would
* be a guess.
*/
export class RemoveGeneralManager3390000000000 implements MigrationInterface {
name = "RemoveGeneralManager3390000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
// 1. Rescue the notification address before the key it lives under is gone.
await queryRunner.query(`
UPDATE freight.companies
SET email = COALESCE(
NULLIF(email, ''),
NULLIF(attributes->>'ownerEmail', ''),
NULLIF(attributes->>'generalManagerEmail', ''),
NULLIF(attributes->>'contactPersonEmail', '')
)
WHERE COALESCE(email, '') = ''
`);
// 2. Backfill the owner from the best source each company actually has:
// its Fayda-verified owner claims (already under owner*), then the general
// manager it named, then its contact person. A company with none of these
// never finished onboarding, and will be asked on its next visit.
await queryRunner.query(`
UPDATE freight.companies
SET attributes = attributes
|| jsonb_strip_nulls(jsonb_build_object(
'ownerName', COALESCE(
NULLIF(attributes->>'ownerName', ''),
NULLIF(attributes->>'generalManagerName', ''),
NULLIF(attributes->>'contactPersonName', '')
),
'ownerEmail', COALESCE(
NULLIF(attributes->>'ownerEmail', ''),
NULLIF(attributes->>'generalManagerEmail', ''),
NULLIF(attributes->>'contactPersonEmail', ''),
NULLIF(email, '')
),
'ownerPhone', COALESCE(
NULLIF(attributes->>'ownerPhone', ''),
NULLIF(attributes->>'generalManagerPhone', ''),
NULLIF(attributes->>'contactPersonPhone', ''),
NULLIF(phone, '')
)
))
WHERE attributes IS NOT NULL
`);
// 2b. Capture eTrade's manager for the owner-vs-licence check the
// backoffice now makes. Nothing stored it before, so the best we have is
// the owner name itself — which makes existing companies read as "matches"
// rather than as a false mismatch on data nobody ever compared. The value
// is refreshed for real on the company's next eTrade lookup.
await queryRunner.query(`
UPDATE freight.companies
SET attributes = jsonb_set(
attributes, '{etradeManagerName}', to_jsonb(attributes->>'ownerName')
)
WHERE COALESCE(attributes->>'ownerName', '') <> ''
AND attributes->>'etradeManagerName' IS NULL
AND COALESCE(licence_number, '') <> ''
`);
// 3a. Owner-represents-themselves, and NOT a forwarder → "no PoA".
await queryRunner.query(`
UPDATE freight.companies c
SET attributes = (c.attributes - 'poaName' - 'poaPhone' - 'poaEmail'
- 'poaLocation' - 'poaAddress' - 'poaFaydaSub'
- 'poaFaydaVerifiedAt' - 'poaBirthdate' - 'poaGender')
|| jsonb_build_object('poaDeclared', 'no')
WHERE (c.attributes->>'poaSameAsOwner')::boolean IS TRUE
AND NOT EXISTS (
SELECT 1 FROM freight.company_profiles cp
WHERE cp.company_id = c.id
AND cp.type = 'freight_forwarder'
AND cp.deleted_at IS NULL
)
`);
// 3b. Forwarders keep their representative and lose the waiver.
await queryRunner.query(`
UPDATE freight.companies c
SET attributes = c.attributes || jsonb_build_object('poaDeclared', 'yes')
WHERE (c.attributes->>'poaSameAsOwner')::boolean IS TRUE
AND EXISTS (
SELECT 1 FROM freight.company_profiles cp
WHERE cp.company_id = c.id
AND cp.type = 'freight_forwarder'
AND cp.deleted_at IS NULL
)
`);
// 4. Everyone else: "yes" if a representative was named or the company is a
// forwarder, "no" if it finished onboarding without one. A company still
// mid-onboarding is left unanswered — it will be asked, which is the point.
await queryRunner.query(`
UPDATE freight.companies c
SET attributes = COALESCE(c.attributes, '{}'::jsonb)
|| jsonb_build_object('poaDeclared', 'yes')
WHERE c.attributes->>'poaDeclared' IS NULL
AND (
COALESCE(c.attributes->>'poaName', '') <> ''
OR COALESCE(c.attributes->>'poaPhone', '') <> ''
OR COALESCE(c.attributes->>'poaEmail', '') <> ''
OR COALESCE(c.attributes->>'poaLocation', '') <> ''
OR COALESCE(c.attributes->>'poaAddress', '') <> ''
OR EXISTS (
SELECT 1 FROM freight.company_profiles cp
WHERE cp.company_id = c.id
AND cp.type = 'freight_forwarder'
AND cp.deleted_at IS NULL
)
)
`);
await queryRunner.query(`
UPDATE freight.companies c
SET attributes = COALESCE(c.attributes, '{}'::jsonb)
|| jsonb_build_object('poaDeclared', 'no')
WHERE c.attributes->>'poaDeclared' IS NULL
AND EXISTS (
SELECT 1 FROM freight.external_profiles ep
WHERE ep.company_id = c.id
AND ep.onboarding_completed = true
AND ep.deleted_at IS NULL
)
`);
// 5. The "personnel" (general manager) wizard step no longer exists; a
// draft resting on it would fall back to the very first step and make the
// customer walk the whole wizard again.
await queryRunner.query(`
UPDATE freight.external_profiles
SET onboarding_step = 'owner'
WHERE onboarding_step = 'personnel'
`);
// 6. Retire the general manager and the flag it shared the model with.
await queryRunner.query(`
UPDATE freight.companies
SET attributes = attributes - 'generalManagerName' - 'generalManagerEmail'
- 'generalManagerPhone' - 'gmSameAsOwner' - 'gmFaydaSub'
- 'gmFaydaVerifiedAt' - 'gmName' - 'gmEmail' - 'gmPhone'
- 'gmAddress' - 'gmBirthdate' - 'gmGender'
- 'poaSameAsOwner'
WHERE attributes IS NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.companies
DROP COLUMN IF EXISTS general_manager_name,
DROP COLUMN IF EXISTS general_manager_email,
DROP COLUMN IF EXISTS general_manager_phone
`);
}
/**
* Restores the columns' shape only. The values, the `gm*` attributes and the
* `poaSameAsOwner` flag are not recoverable — this migration folded them into
* `ownerEmail` / `poaDeclared`, and there is no way back that isn't a guess.
*/
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.companies
ADD COLUMN IF NOT EXISTS general_manager_name varchar(100),
ADD COLUMN IF NOT EXISTS general_manager_email varchar(150),
ADD COLUMN IF NOT EXISTS general_manager_phone varchar(20)
`);
await queryRunner.query(`
UPDATE freight.external_profiles
SET onboarding_step = 'personnel'
WHERE onboarding_step = 'owner'
`);
}
}

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Single-row table holding the one company stamp/seal image stamped onto
* generated invoice/receipt PDFs (see StampSettingsService /
* InvoiceDocumentService). Same single-row shape as exchange_settings; the
* app never inserts more than one row.
*/
export class StampSettings3400000000000 implements MigrationInterface {
name = "StampSettings3400000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.stamp_settings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
stamp_file_id uuid REFERENCES freight.files(id),
updated_by_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.stamp_settings;`);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,32 +1,46 @@
import { Controller, Get, Query } from "@nestjs/common";
import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
import { Controller, Get, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { PaginatedResponse } from '@edr/types';
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { AuditService } from "./audit.service";
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { AuditService } from './audit.service';
import { AuditLog } from './entities/audit-log.entity';
import { AuditLogQueryDto } from './dto/audit-log-query.dto';
@ApiTags("audit")
@Controller("audit")
@BookingStaff(FREIGHT_PERMS.audit.view)
/**
* Read-only view over the audit trail.
*
* Gated on `edr_freight_app:audit_log:view` — a dedicated view key rather than
* the broad `admin` key, so reading the trail can be granted without also
* granting write access to everything else.
*
* There is deliberately no write, update or delete endpoint here — rows are
* created only by `AuditInterceptor`, and an audit trail that can be edited
* through the API is not an audit trail.
*/
@ApiTags('audit')
@ApiBearerAuth()
@Controller('audit')
export class AuditController {
constructor(private readonly auditService: AuditService) {}
@Get("logs")
@ApiOperation({ summary: "List freight-api audit log commands" })
@ApiQuery({ name: "skip", type: Number, required: false })
@ApiQuery({ name: "take", type: Number, required: false })
list(@Query("skip") skip?: string, @Query("take") take?: string) {
// Same fallback chain @tria-plc/auditlog's client interceptor uses to
// stamp AuditLog.application (mezgeb/client/client-audit.interceptor.js)
// — reading it here instead of a hardcoded literal means this can't
// silently drift out of sync with whatever APPLICATION_NAME/APP_NAME
// actually is at runtime.
const application =
process.env.APPLICATION_NAME ?? process.env.APP_NAME ?? "DEFAULT";
return this.auditService.list(
application,
skip !== undefined ? parseInt(skip, 10) : undefined,
take !== undefined ? parseInt(take, 10) : undefined,
);
@Get('logs')
@BookingStaff(FREIGHT_PERMS.auditLog.view)
@ApiOperation({
summary:
'List backoffice audit logs — filter by entity type, user, method, outcome and date range',
})
list(@Query() query: AuditLogQueryDto): Promise<PaginatedResponse<AuditLog>> {
return this.auditService.search(query);
}
@Get('types')
@BookingStaff(FREIGHT_PERMS.auditLog.view)
@ApiOperation({
summary: 'Distinct entity types present in the audit log (filter dropdown)',
})
types(): Promise<string[]> {
return this.auditService.listTypes();
}
}

View File

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

View File

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

View File

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

View File

@@ -1,70 +1,71 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { AuditLogCommand } from "@tria-plc/auditlog";
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { PaginatedResponse } from '@edr/types';
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()
export class AuditService {
constructor(
@InjectRepository(AuditLogCommand)
private readonly auditLogCommandRepository: Repository<AuditLogCommand>,
) {}
private readonly logger = new Logger(AuditService.name);
async list(
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();
constructor(private readonly auditLogRepository: AuditLogRepository) {}
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();
}
}

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import {
NotFoundException,
} from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { logCtx } from "@edr/api-common";
import { DataSource, EntityManager, In } from "typeorm";
import { Booking } from "../bookings/entities/booking.entity";
@@ -951,6 +952,24 @@ export class BillingService {
await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
// Every invoice status move in the app funnels through here — money
// changing state is the single most-asked question in support.
logCtx(
{
invoiceId: invoice.id,
invoiceNumber: invoice.invoiceNumber,
source: invoice.source,
sourceId: invoice.sourceId,
from: invoice.status,
to: status,
event,
amount: Number(invoice.totalAmount),
currency: invoice.currency,
paymentId: extra.paymentId ?? invoice.paymentId ?? undefined,
},
{ path: "invoiceTransitions", mode: "push" },
);
const updated = { ...invoice, ...extra, status } as Invoice;
return {
result: updated,
@@ -1320,6 +1339,21 @@ export class BillingService {
throw new BadRequestException("Invoice has no outstanding balance.");
}
logCtx(
{
invoiceId: invoice.id,
invoiceNumber: invoice.invoiceNumber,
source: invoice.source,
sourceId: invoice.sourceId,
companyId: invoice.companyId,
amountDue,
currency: invoice.currency,
method: opts.method ?? "TELEBIRR",
platform: opts.platform,
},
{ path: "payment.payInvoice" },
);
// CAC Bank is an OTP debit — the bank SMSes the code to this number, so it is
// required up front (the payment service rejects it otherwise, as a 502 here).
if (
@@ -1443,7 +1477,24 @@ export class BillingService {
// first on DESC, which would hand back an unissued invoice.
order: { issuedAt: { direction: "DESC", nulls: "LAST" } },
});
if (!invoice) return null;
if (!invoice) {
logCtx(
{ paymentId, outcome: "no-invoice-for-payment" },
{ path: "payment.settleInvoice" },
);
return null;
}
logCtx(
{
paymentId,
providerTxnId,
invoiceId: invoice.id,
invoiceNumber: invoice.invoiceNumber,
invoiceStatus: invoice.status,
},
{ path: "payment.settleInvoice" },
);
const settleable: Freight.InvoiceStatus[] = [
...OPEN_STATUSES,
@@ -1453,6 +1504,12 @@ export class BillingService {
// Already PAID is the ordinary idempotent no-op (redelivery, or settled
// inline by payInvoice). Anything else means money was captured with
// nowhere to land — that needs a person, so say so loudly.
logCtx(
invoice.status === Freight.InvoiceStatus.Paid
? "already-paid"
: "captured-with-nowhere-to-land",
{ path: "payment.settleInvoice.outcome", mode: "set" },
);
if (invoice.status !== Freight.InvoiceStatus.Paid) {
this.logger.error(
`Payment ${paymentId} succeeded but invoice ${invoice.invoiceNumber} ` +

View File

@@ -7,7 +7,9 @@ import { PdfRenderService } from "./pdf-render.service";
* Standalone document infrastructure — generic HTML→PDF plus the shared
* invoice/receipt renderer. Has no domain dependencies, so any module (billing,
* warehouses, …) can import it to print invoices without coupling to the
* billing payment graph.
* billing payment graph. StampSettingsService is @Global (see
* StampSettingsModule) so InvoiceDocumentService can inject it without this
* module declaring an explicit import.
*/
@Module({
providers: [PdfRenderService, InvoiceDocumentService],

View File

@@ -1,6 +1,8 @@
import { Injectable } from "@nestjs/common";
import { StampSettingsService } from "../../stamp-settings/stamp-settings.service";
import { PdfRenderService } from "./pdf-render.service";
import { sealClass, sealImageCss, sealMarkup } from "./seal-markup.util";
import {
PdfColor,
assembleSinglePagePdf,
@@ -53,6 +55,13 @@ export interface InvoiceDocumentModel {
totals: InvoiceDocumentTotal[];
/** Override the round seal text; defaults from kind/status. */
sealText?: string;
/**
* Company stamp image (data URL) to render instead of the plain text seal.
* Callers normally leave this unset — `InvoiceDocumentService.render()`
* fills it in from the single global stamp in StampSettingsService; set it
* explicitly only to override that default for one document.
*/
stampImageUrl?: string | null;
}
/**
@@ -63,12 +72,21 @@ export interface InvoiceDocumentModel {
*/
@Injectable()
export class InvoiceDocumentService {
constructor(private readonly pdf: PdfRenderService) {}
constructor(
private readonly pdf: PdfRenderService,
private readonly stampSettings: StampSettingsService,
) {}
async render(
model: InvoiceDocumentModel,
): Promise<{ filename: string; buffer: Buffer }> {
const html = this.buildHtml(model);
const stampImageUrl =
model.stampImageUrl !== undefined
? model.stampImageUrl
: await this.stampSettings.getStampImageUrl();
const resolvedModel: InvoiceDocumentModel = { ...model, stampImageUrl };
const html = this.buildHtml(resolvedModel);
const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice";
return {
filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`,
@@ -77,7 +95,11 @@ export class InvoiceDocumentService {
// Chromium-less fallback: draw a genuine styled invoice (header, seal,
// summary grid, line-item table, totals) from the model — not a flat
// plain-text dump — so it still reads as a proper invoice document.
fallback: () => this.buildFallbackPdf(model),
// ponytail: still draws the plain vector seal, not the uploaded stamp
// image — embedding a raster image needs a new PDF XObject primitive
// in styled-pdf.util.ts. Upgrade when the Chromium-less path needs to
// carry the real stamp too; today it's a rare degraded fallback.
fallback: () => this.buildFallbackPdf(resolvedModel),
}),
};
}
@@ -218,6 +240,8 @@ export class InvoiceDocumentService {
const showCategory = Boolean(model.categoryHeader);
const sealText =
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
const sealInner = sealMarkup(model.stampImageUrl, sealText);
const sealCssClass = sealClass(model.stampImageUrl);
const summaryRows = model.summary
.map((row) => `<div><span>${esc(row.label)}</span>${esc(row.value)}</div>`)
@@ -256,6 +280,7 @@ export class InvoiceDocumentService {
.meta { text-align: right; font-size: 12px; color: #475569; }
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
${sealImageCss()}
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
@@ -283,7 +308,7 @@ export class InvoiceDocumentService {
Issued: ${esc(date(model.issuedAt))}
</div>
</div>
<div class="seal">${esc(sealText)}</div>
<div class="${sealCssClass}">${sealInner}</div>
<div class="summary">${summaryRows}</div>
<table>
<thead>

View File

@@ -0,0 +1,78 @@
import { sealClass, sealImageCss, sealMarkup } from "./seal-markup.util";
/**
* These three helpers are the single image-vs-text branch shared by every
* EDR document's round seal, so a regression here silently unstamps invoices,
* warehouse release papers and handover papers at once.
*/
describe("seal markup helpers", () => {
const STAMP = "data:image/png;base64,QUJD";
describe("sealMarkup", () => {
it("renders the stamp image when one is configured", () => {
expect(sealMarkup(STAMP, ["EDR", "Warehouse"])).toBe(
`<img src="${STAMP}" alt="Company stamp" />`,
);
});
it("falls back to text rings when no stamp is configured", () => {
expect(sealMarkup(null, ["EDR", "Warehouse", "Cleared"])).toBe(
"<span>EDR<br />Warehouse<br />Cleared</span>",
);
});
it("treats undefined as unset", () => {
expect(sealMarkup(undefined, "EDR")).toBe("<span>EDR</span>");
});
it("accepts a bare string as a single line", () => {
expect(sealMarkup(null, "EDR")).toBe("<span>EDR</span>");
});
it("escapes text lines so document data cannot inject markup", () => {
expect(sealMarkup(null, ['<script>alert("x")</script>'])).toBe(
"<span>&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;</span>",
);
});
it("escapes the image src so it cannot break out of the attribute", () => {
expect(sealMarkup('data:image/png;base64,A" onerror="x', "EDR")).toBe(
'<img src="data:image/png;base64,A&quot; onerror=&quot;x" alt="Company stamp" />',
);
});
});
describe("sealClass", () => {
it("adds the image modifier only when stamped", () => {
expect(sealClass(STAMP)).toBe("seal seal-image");
expect(sealClass(null)).toBe("seal");
});
it("honours a document's own seal selector", () => {
expect(sealClass(STAMP, "sig-stamp-box")).toBe(
"sig-stamp-box sig-stamp-box-image",
);
expect(sealClass(null, "sig-stamp-box")).toBe("sig-stamp-box");
});
});
describe("sealImageCss", () => {
it("neutralizes the drawn ring and rotation for a real stamp image", () => {
const css = sealImageCss();
expect(css).toContain(".seal.seal-image { border: none;");
expect(css).toContain("transform: none;");
// The ::before pseudo-element draws the inner ring of the text seal.
expect(css).toContain(".seal.seal-image::before { content: none; }");
expect(css).toContain(".seal img { max-width: 100%;");
});
it("scopes every rule to the given selector", () => {
const css = sealImageCss("sig-stamp-box");
expect(css).not.toContain(".seal");
expect(css).toContain(".sig-stamp-box.sig-stamp-box-image");
expect(css).toContain(".sig-stamp-box img");
});
});
});

View File

@@ -0,0 +1,64 @@
/**
* The single decision every EDR document makes about its round seal: draw the
* one uploaded company stamp when one is configured (StampSettingsService), or
* fall back to the plain text rings the document styles itself.
*
* Only the image-vs-text branch and the image overrides live here — each
* document keeps its own `.seal` geometry (the invoice's seal is absolutely
* positioned top-right, the warehouse papers' sit inline above the signature
* lines), so centralizing the source of the stamp does not relayout anything.
*
* These helpers are for the HTML/Chromium render path. The hand-built vector
* fallbacks in styled-pdf.util.ts cannot embed a raster image and continue to
* draw their vector seal — see InvoiceDocumentService for that caveat.
*/
/** Escape a value for interpolation into HTML text or a quoted attribute. */
function escapeHtml(value: unknown): string {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/**
* CSS overrides that neutralize a document's own ring/rotation styling when the
* seal is a real stamp image. Append inside a document's <style> block, after
* its own `.seal` rules. `selector` is the document's seal class ("seal").
*/
export function sealImageCss(selector = "seal"): string {
return [
`.${selector}.${selector}-image { border: none; border-radius: 0; opacity: 1; transform: none; }`,
`.${selector}.${selector}-image::before { content: none; }`,
`.${selector} img { max-width: 100%; max-height: 100%; object-fit: contain; }`,
].join("\n ");
}
/**
* Inner markup for the seal element: the stamp image, or the given text lines
* wrapped in a <span> (matching the `.seal span { position: relative }` rule
* the ring-drawing documents rely on).
*
* `stampImageUrl` is expected to be a data URL from
* StampSettingsService.getStampImageUrl(); null renders the text fallback.
*/
export function sealMarkup(
stampImageUrl: string | null | undefined,
textLines: string | string[],
): string {
if (stampImageUrl) {
return `<img src="${escapeHtml(stampImageUrl)}" alt="Company stamp" />`;
}
const lines = Array.isArray(textLines) ? textLines : [textLines];
return `<span>${lines.map(escapeHtml).join("<br />")}</span>`;
}
/** Class attribute for the seal element — adds the image modifier when stamped. */
export function sealClass(
stampImageUrl: string | null | undefined,
selector = "seal",
): string {
return stampImageUrl ? `${selector} ${selector}-image` : selector;
}

View File

@@ -304,6 +304,7 @@ export class BookingContractService {
async getSignatures(bookingId: string) {
const rows = await this.bookingsRepository.findContractSignatures(bookingId);
const views = rows.map((r) => this.viewModelBuilder.toSignatureView(r));
await this.viewModelBuilder.attachProviderStamp(views);
await this.inlineSignatureImages(views);
return { signatures: views };
}

View File

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

View File

@@ -1,4 +1,4 @@
import { BaseRepository } from '@edr/api-common';
import { BaseRepository, logCtx } from '@edr/api-common';
import { SchedulingStatus } from '@edr/types';
import { ConflictException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
@@ -631,6 +631,13 @@ export class BookingsRepository extends BaseRepository<Booking> {
authorId?: string,
): Promise<BookingReviewNote> {
const repo = this.dataSource.getRepository(BookingReviewNote);
// Every rejection/cancellation/change-request reason in the booking flow is
// written through here — the "why" behind the status change on the same log
// line as the status change itself.
logCtx(
{ bookingId, type, note, authorId },
{ path: 'reviewNotes', mode: 'push' },
);
return repo.save(
repo.create({ bookingId, note, type, authorId: authorId ?? null }),
);

View File

@@ -9,7 +9,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { Freight, SchedulingStatus } from '@edr/types';
import { insertWithGeneratedReference } from '@edr/api-common';
import { insertWithGeneratedReference, logCtx } from '@edr/api-common';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
@@ -59,9 +59,11 @@ import { PdfRenderService } from '../billing/documents/pdf-render.service';
import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util';
/**
* The allocated train as the backoffice booking detail page needs it: which
* train, its window phase, and both the planned and actual clock. Attached by
* `findById` only when the booking is on a schedule.
* The train as the backoffice booking detail page needs it: which train, its
* window phase, and both the planned and actual clock. Attached by `findById`
* for the allocated train (`train_schedule_id`) or, before the batch engine has
* allocated one, the train the customer picked at day-commit
* (`requested_train_schedule_id`) — see `isRequested`.
*/
export interface TrainScheduleSummary {
id: string;
@@ -74,6 +76,12 @@ export interface TrainScheduleSummary {
actualArrivalAt: string | null;
windowPhase: string | null;
paymentPhaseEndsAt: string | null;
/**
* True when this is the customer's requested train rather than a confirmed
* allocation — the state staff review at OPERATION_REQUEST_PENDING, before
* accepting the operation puts the booking into the batch pool.
*/
isRequested: boolean;
}
/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
@@ -2107,6 +2115,21 @@ export class BookingsService {
throw new NotFoundException(`Booking ${id} not found`);
}
// Nearly every booking flow loads the booking through here, so this one
// call puts the human-searchable reference + entry state on the request log
// line for all of them. Entry state only — the write trail (statusChanges)
// shows where it ended up.
logCtx(
{
id: booking.id,
reference: booking.reference,
statusAtEntry: booking.status,
companyId: booking.companyId,
contractId: booking.contractId ?? undefined,
},
{ path: "booking" },
);
if (booking.files && booking.files.length > 0) {
booking.files = await Promise.all(
booking.files.map(async (file: FileRecord) => {
@@ -2132,15 +2155,23 @@ export class BookingsService {
// Surface the assigned train's operational status so the portal stepper
// can show the Arrival stage: the booking status stays IN_TRANSIT from
// dispatch until delivery, so arrival is only knowable from the schedule.
if (booking.trainScheduleId) {
// The allocated train, or — before the batch engine has allocated one — the
// train the customer picked at day-commit. Staff reviewing an operation
// request (OPERATION_REQUEST_PENDING) must see which train they are
// accepting onto before they approve, and at that point only the requested
// id is set.
const summarySourceId = booking.trainScheduleId ?? booking.requestedTrainScheduleId;
if (summarySourceId) {
const schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: booking.trainScheduleId } });
.findOne({ where: { id: summarySourceId } });
// trainScheduleStatus drives the portal's Arrival stage, so it stays tied
// to a real allocation — a merely requested train has not departed.
(booking as Booking & { trainScheduleStatus?: string | null }).trainScheduleStatus =
schedule?.status ?? null;
// Backoffice staff view: the allocated train's identity and clock, so the
// detail page can state which train the booking rides and when it runs
// without a second round-trip to the schedules API.
booking.trainScheduleId ? (schedule?.status ?? null) : null;
// Backoffice staff view: the train's identity and clock, so the detail
// page can state which train the booking rides and when it runs without a
// second round-trip to the schedules API.
(
booking as Booking & { trainScheduleSummary?: TrainScheduleSummary | null }
).trainScheduleSummary = schedule
@@ -2155,6 +2186,7 @@ export class BookingsService {
actualArrivalAt: schedule.actualArrivalAt?.toISOString() ?? null,
windowPhase: schedule.windowPhase ?? null,
paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null,
isRequested: !booking.trainScheduleId,
}
: null;
}

View File

@@ -12,6 +12,12 @@ export class ContractSignatureDto {
@ApiPropertyOptional()
signatureImageUrl?: string | null;
@ApiPropertyOptional({
description:
'Company seal beside the signature. Set for the EDR (STAFF) side from the single global company stamp; null for the client side.',
})
stampImageUrl?: string | null;
}
export class SavedSignatureViewDto {

View File

@@ -39,6 +39,7 @@ import {
CompleteIdentityVerificationDto,
} from "./dto/complete-identity-verification.dto";
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
import { SetPoaDeclaredDto } from "./dto/set-poa-declared.dto";
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
import { DashboardQueryDto } from "./dto/dashboard-query.dto";
import {
@@ -265,6 +266,7 @@ export class CompaniesController {
dto.companyType,
dto.roles,
dto.nationality,
dto.cooperative,
);
return new CompanyInfoResponseDto(profile, company);
}
@@ -415,90 +417,31 @@ export class CompaniesController {
@PortalCustomer()
@ApiOperation({
summary:
"Bind a completed Fayda verification to the company's owner or Power of Attorney. " +
"Bind a completed Fayda verification to the company's single identity. " +
"Start the flow with POST /fayda/verification/start (platform=PORTAL), then post the returned code+state here. " +
"`subject` must match the company's PoA declaration — the representative when one is named, otherwise the owner. " +
"The verified name, phone, email and address are written from the Fayda payload; on an approved company the change is staged for backoffice review.",
})
async completeIdentityVerification(
@CurrentUser() user: CurrentIamUser,
@Body() dto: CompleteIdentityVerificationDto,
): Promise<CompanyIdentityStateDto> {
return this.companiesService.completeIdentityVerification(user.id, dto, {
email: user.email,
phoneNumber: user.phoneNumber,
});
return this.companiesService.completeIdentityVerification(user.id, dto);
}
@Post("identity/gm/same-as-owner")
@Patch("identity/poa-declared")
@PortalCustomer()
@ApiOperation({
summary:
"Declare the General Manager is the company's owner, copying the owner's verified identity across. " +
"Refused until the owner is Fayda-verified — there would be nothing proven to copy.",
"Answer whether anyone holds power of attorney for this company — the question that decides whose identity is verified. " +
'Answering "no" removes the representative entirely: their details, their verification, their passport number and the DARS delegation paper. ' +
'Refused for a freight forwarder, which cannot operate without a representative (its answer is always "yes").',
})
async setGmSameAsOwner(
async setPoaDeclared(
@CurrentUser() user: CurrentIamUser,
@Body() dto: SetPoaDeclaredDto,
): Promise<CompanyIdentityStateDto> {
return this.companiesService.setGmSameAsOwner(user.id, {
email: user.email,
phoneNumber: user.phoneNumber,
});
}
@Delete("identity/gm")
@PortalCustomer()
@ApiOperation({
summary:
"Clear the General Manager's identity — the \"same as owner\" declaration or a verification, and the details either wrote. " +
"Leaves the GM open to be verified in their own right, or typed where Fayda is optional.",
})
async clearGmIdentity(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyIdentityStateDto> {
return this.companiesService.clearGmIdentity(user.id);
}
@Post("identity/poa/same-as-owner")
@PortalCustomer()
@ApiOperation({
summary:
"Declare the Power of Attorney is the company's owner, copying the owner's identity across. " +
"Waives the DARS delegation paper — nobody delegates to themselves. " +
"Refused for an Ethiopian company whose owner is not Fayda-verified yet: its representative must be verified, and there would be nothing proven to copy.",
})
async setPoaSameAsOwner(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyIdentityStateDto> {
return this.companiesService.setPoaSameAsOwner(user.id, {
email: user.email,
phoneNumber: user.phoneNumber,
});
}
@Delete("identity/poa/same-as-owner")
@PortalCustomer()
@ApiOperation({
summary:
"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. " +
"Unlike DELETE identity/fayda/poa this is allowed for a freight forwarder — it is how they change who represents them — and leaves the delegation paper on file.",
})
async clearPoaSameAsOwner(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyIdentityStateDto> {
return this.companiesService.clearPoaSameAsOwner(user.id);
}
@Delete("identity/fayda/poa")
@PortalCustomer()
@ApiOperation({
summary:
"Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together. " +
"Refused while the company holds a freight forwarder role, which cannot operate without a representative.",
})
async removePoaIdentity(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyIdentityStateDto> {
return this.companiesService.removePoaIdentity(user.id);
return this.companiesService.setPoaDeclared(user.id, dto.declared);
}
@Patch("onboarding-step")

View File

@@ -174,21 +174,26 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
).resolves.toBeDefined();
});
it("waives the paper when the owner represents the company themselves", async () => {
// Nobody delegates to themselves, so a self-declared PoA owes no DARS
// paper — the representative's own details are still required.
it('owes nothing when the company answered "no representative"', async () => {
// "The owner represents the company themselves" is now expressed as the
// declaration being "no" — there is no delegation, so no paper is due. The
// representative's details are cleared with the answer, so there is nothing
// left to evidence either.
const { service } = makeService({
attributes: { ...VERIFIED_IDENTITIES, poaSameAsOwner: true },
attributes: { ...VERIFIED_IDENTITIES, poaDeclared: "no" },
});
await expect(
service.updateProfile("user-1", POA as never),
service.updateProfile("user-1", {} as never),
).resolves.toBeDefined();
});
it("grants the forwarder role to a self-represented company with no paper", async () => {
it("refuses the forwarder role without a paper, however it represents itself", async () => {
// The self-representation waiver is gone: a freight forwarder signs on
// other companies' behalf, so the delegation and the paper evidencing it
// are non-negotiable.
const { service } = makeService({
attributes: { ...VERIFIED_IDENTITIES, ...POA, poaSameAsOwner: true },
attributes: { ...VERIFIED_IDENTITIES, ...POA, poaDeclared: "yes" },
});
await expect(
@@ -196,7 +201,7 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
"user-1",
ProfileType.freightForwarder,
),
).resolves.toBeDefined();
).rejects.toBeInstanceOf(BadRequestException);
});
it("rejects a paper the reviewer sent back for correction", async () => {

View File

@@ -26,7 +26,13 @@ function makeService(existing: ExistingProfile[]) {
})),
softDelete: jest.fn(async () => undefined),
};
const companiesRepo = { update: jest.fn(async () => null) };
// `findById` is only consulted when the co-operative flag is in play (adding
// a forwarder role, or setting the flag itself) — a plain company row is the
// right answer for every case here.
const companiesRepo = {
update: jest.fn(async () => null),
findById: jest.fn(async () => ({ id: "company-1", attributes: {} })),
};
const profilesRepo = {
findByUserId: jest.fn(async () => ({
id: "external-1",

File diff suppressed because it is too large Load Diff

View File

@@ -23,9 +23,18 @@ export const COMPANY_FIELD_LABELS: Record<string, string> = {
contactPersonPhone: "Contact person phone",
contactPersonEmail: "Contact person email",
contactPersonPosition: "Contact person position",
generalManagerName: "General manager name",
generalManagerPhone: "General manager phone",
generalManagerEmail: "General manager email",
ownerName: "Owner name",
ownerPhone: "Owner phone",
ownerEmail: "Owner email",
ownerPassportNumber: "Owner passport number",
poaPassportNumber: "PoA passport number",
poaDeclared: "Has a Power of Attorney",
// Nothing writes these any more (the general manager was removed), but
// revisions and change requests filed before that still carry them — without
// the labels those rows render raw attribute keys to a reviewer.
generalManagerName: "General manager name (retired)",
generalManagerPhone: "General manager phone (retired)",
generalManagerEmail: "General manager email (retired)",
poaName: "PoA name",
poaPhone: "PoA phone",
poaEmail: "PoA email",

View File

@@ -5,21 +5,61 @@ import { Company, CompanyNationality } from "../entities/company.entity";
import { ProfileType } from "../entities/company-profile.entity";
/**
* The three people a company is verified through — its owner, its Power of
* Attorney and its General Manager. The owner is the person the company's
* existence is proven by; the other two are personnel it names.
* The two people a company can be described through.
*
* The GM is very often the owner, which is what the portal's "same as owner"
* copy is for: that path reuses the owner's verified identity outright rather
* than asking the same human to verify twice.
* The **owner** is whoever the eTrade TIN record names as the business's
* manager. Not necessarily the legal owner — eTrade's `ManagerNameEng` is
* simply the person on the licence — but that is the point: whoever the company
* puts forward here has to match the eTrade record, and the backoffice check is
* exactly that comparison (see `ownerMatchesEtrade`).
*
* The **Power of Attorney** is who the company delegates to act for it, when it
* delegates at all.
*
* Exactly ONE of them is identity-verified, and which one is decided by the
* company's own answer (see {@link PoaDeclaration}): the representative if
* there is one, otherwise the owner. There is no general manager — the concept
* was removed; it named who to talk to and gated nothing.
*/
export const IDENTITY_SUBJECTS = ["owner", "poa", "gm"] as const;
export const IDENTITY_SUBJECTS = ["owner", "poa"] as const;
export type IdentitySubject = (typeof IDENTITY_SUBJECTS)[number];
/**
* The company's answer to "does anyone hold power of attorney for you?".
*
* Explicit rather than derived from "are any `poa*` keys set", because "no" is
* an answer that moves the verification onto the owner, while *absent* is a
* question the customer has not reached yet. Stored on `company.attributes`
* under {@link POA_DECLARED_KEY}.
*
* A freight forwarder never gets to answer: it signs on other companies'
* behalf, so a Power of Attorney (and the DARS paper evidencing it) is
* non-negotiable. {@link readPoaDeclaration} forces "yes" for them, which is
* why the declaration is read through that helper rather than off the blob.
*/
export const POA_DECLARATIONS = ["yes", "no"] as const;
export type PoaDeclaration = (typeof POA_DECLARATIONS)[number];
/** `company.attributes` key holding the {@link PoaDeclaration}. */
export const POA_DECLARED_KEY = "poaDeclared";
/**
* `company.attributes` keys holding the eTrade record's own manager, captured
* at lookup time.
*
* Kept apart from `ownerName`/`ownerPhone` — which are what the *company*
* asserts, and what a Fayda verification overwrites — precisely so the two can
* be compared. Storing only one value would leave the reviewer comparing the
* owner field against itself.
*/
export const ETRADE_MANAGER_NAME_KEY = "etradeManagerName";
export const ETRADE_MANAGER_PHONE_KEY = "etradeManagerPhone";
export class CompleteIdentityVerificationDto {
@ApiProperty({
enum: IDENTITY_SUBJECTS,
description: "Which of the company's people this verification is for.",
description:
"Which of the company's people this verification is for. Must match the company's PoA declaration — the representative when one is named, the owner when not.",
})
@IsIn(IDENTITY_SUBJECTS)
subject!: IdentitySubject;
@@ -35,9 +75,11 @@ export class CompleteIdentityVerificationDto {
state!: string;
}
/** One person's verification state, as reported back to the portal. */
/** One person's identity state, as reported back to the portal. */
export class IdentityVerificationStateDto {
@ApiProperty() verified!: boolean;
@ApiProperty({ description: "True once a Fayda verification is bound." })
verified!: boolean;
@ApiProperty({ nullable: true }) name!: string | null;
@ApiProperty({ nullable: true }) phone!: string | null;
@ApiProperty({ nullable: true }) email!: string | null;
@@ -45,13 +87,11 @@ export class IdentityVerificationStateDto {
@ApiProperty({ nullable: true }) verifiedAt!: string | null;
@ApiProperty({ nullable: true }) birthdate!: string | null;
@ApiProperty({ nullable: true }) gender!: string | null;
}
export class OwnerIdentityStateDto extends IdentityVerificationStateDto {
@ApiProperty({
nullable: true,
description:
"Typed passport number — the foreign-company identity credential. Independent of Fayda: never written by a verification, and still required even if the owner also verifies.",
"Typed passport number. Fayda is an Ethiopian national ID, so a foreign company proves the identity with either — this is the alternative, not an addition.",
})
passportNumber!: string | null;
}
@@ -59,44 +99,62 @@ export class OwnerIdentityStateDto extends IdentityVerificationStateDto {
export class CompanyIdentityStateDto {
@ApiProperty({
description:
"True when Fayda verification of the owner (and PoA, once named) is mandatory — Ethiopian companies only.",
"True for a foreign company: a typed passport number proves the identity just as a Fayda verification does. An Ethiopian company must use Fayda.",
})
faydaRequired!: boolean;
passportAccepted!: boolean;
@ApiProperty({
enum: POA_DECLARATIONS,
nullable: true,
description:
"True when the owner's passport number is mandatory — foreign companies only. Independent of faydaRequired: a foreign owner may verify with Fayda too, but the passport is still required.",
'Whether the company named a Power of Attorney. Null until the customer answers — which is itself an outstanding onboarding item, since the answer decides who verifies.',
})
passportRequired!: boolean;
poaDeclared!: PoaDeclaration | null;
@ApiProperty({ type: OwnerIdentityStateDto })
owner!: OwnerIdentityStateDto;
@ApiProperty({
enum: IDENTITY_SUBJECTS,
nullable: true,
description:
"Who the company's single identity verification belongs to: the PoA when one is named, the owner when not. Null while the declaration is unanswered.",
})
subject!: IdentitySubject | null;
@ApiProperty({ type: IdentityVerificationStateDto })
owner!: IdentityVerificationStateDto;
@ApiProperty({ type: IdentityVerificationStateDto })
poa!: IdentityVerificationStateDto;
@ApiProperty({
description:
"True when the Power of Attorney is the company's owner, declared through the portal's \"same as owner\" copy. Waives the DARS delegation paper — nobody delegates to themselves.",
"True once the subject is proven — Fayda-verified, or carrying a passport number where that is accepted.",
})
poaSameAsOwner!: boolean;
identityProven!: boolean;
@ApiProperty({
type: IdentityVerificationStateDto,
nullable: true,
description:
"General manager. `verified` is true both when the GM verified with Fayda in their own right and when the company declared the GM is the owner — in the latter case the owner's Fayda sub backs it.",
"The manager named on the eTrade licence, captured at lookup. Null when eTrade returned none (ManagerNameEng is frequently blank).",
})
gm!: IdentityVerificationStateDto;
etradeManagerName!: string | null;
@ApiProperty({
nullable: true,
description:
"That manager's phone, normalized to E.164 and captured at the same lookup. Paired with the name so the portal can still tell that the owner's details came from eTrade after a refresh, when the live lookup result is long gone — without it a resumed wizard offers them back as typeable inputs.",
})
etradeManagerPhone!: string | null;
@ApiProperty({
nullable: true,
description:
"Does the owner the company put forward match the person on the eTrade licence? This is the backoffice's check. Null when there is nothing to compare — no eTrade manager on file, or no owner name yet. Advisory, not a gate: eTrade's Latin transliteration and Fayda's rarely agree character-for-character, so a reviewer decides.",
})
ownerMatchesEtrade!: boolean | null;
@ApiProperty({
description:
"True when the GM's identity is the owner's, declared through the portal's \"same as owner\" copy rather than a separate verification.",
})
gmSameAsOwner!: boolean;
@ApiProperty({
description:
"False while a mandatory requirement (Fayda for Ethiopian, passport for foreign) is still outstanding.",
"False while the declaration is unanswered or the subject is unproven. Field-level completeness (owner/PoA details, documents) is reported separately by the onboarding requirements.",
})
complete!: boolean;
}
@@ -105,26 +163,9 @@ export class CompanyIdentityStateDto {
const PREFIX: Record<IdentitySubject, string> = {
owner: "owner",
poa: "poa",
gm: "gm",
};
/**
* Typed GM fields, kept in step with the Fayda-written ones.
*
* The GM predates this verification: its details are plain company columns
* that three notifier services mail (booking-lifecycle, train-scheduling and
* contract notifiers all read `company.generalManagerEmail`). A verification
* therefore writes BOTH — the `gm*` attributes carry the proof, these carry
* the value everything else already reads — and an unverified company keeps
* showing whatever was typed before this existed.
*/
const GM_TYPED_KEYS = {
name: "generalManagerName",
email: "generalManagerEmail",
phone: "generalManagerPhone",
} as const;
/** company.attributes keys that together mean "a PoA was entered". */
/** `company.attributes` keys that together mean "a representative was entered". */
const POA_KEYS = [
"poaName",
"poaPhone",
@@ -139,7 +180,7 @@ function stateFor(
): IdentityVerificationStateDto {
const p = PREFIX[subject];
const read = (key: string) => (attrs[key] as string | undefined) ?? null;
const state: IdentityVerificationStateDto = {
return {
verified: Boolean(read(`${p}FaydaSub`)),
name: read(`${p}Name`),
phone: read(`${p}Phone`),
@@ -148,88 +189,114 @@ function stateFor(
verifiedAt: read(`${p}FaydaVerifiedAt`),
birthdate: read(`${p}Birthdate`),
gender: read(`${p}Gender`),
};
if (subject !== "gm" || state.verified) return state;
// Companies onboarded before the GM was verifiable have typed details and no
// `gm*` attributes at all. Report those rather than a blank card — they are
// still what the notifiers mail — leaving `verified` false so the portal
// offers the upgrade instead of pretending the identity is proven.
//
// Only for such an unverified GM, which is the whole population this exists
// for. Merging the typed columns into a *verified* manager's state would read
// back the email the portal asked them to type when Fayda supplied none, and
// the input offering it — keyed on that value being absent — would vanish the
// moment it was saved, leaving a typo uncorrectable.
return {
...state,
name: state.name ?? read(GM_TYPED_KEYS.name),
email: state.email ?? read(GM_TYPED_KEYS.email),
phone: state.phone ?? read(GM_TYPED_KEYS.phone),
passportNumber: read(`${p}PassportNumber`),
};
}
/**
* Derive both people's verification state from the company row.
* The company's PoA declaration, or null when it hasn't answered yet.
*
* Pure and shared: `CompaniesService` gates on it and `ProfileResponseDto`
* renders from it, so the settings page and the onboarding wizard can never
* disagree with the rule the API actually enforces.
* A freight forwarder is never asked: it acts on other companies' behalf, so a
* representative and the DARS paper behind them are mandatory. Forcing it here
* — rather than only disabling the radio in the portal — is what stops a
* forwarder role added *after* onboarding from inheriting an old "no".
*/
export function readPoaDeclaration(
company: Pick<Company, "attributes" | "companyProfiles">,
): PoaDeclaration | null {
if (
(company.companyProfiles ?? []).some(
(p) => p.type === ProfileType.freightForwarder,
)
) {
return "yes";
}
const value = company.attributes?.[POA_DECLARED_KEY];
if (value === "yes" || value === "no") return value;
// No explicit answer, but the company holds a representative's details —
// so it has one, and owes everything a representative brings with them.
//
// Covers rows that predate the question (the migration derives the same way)
// and any write that reaches the attributes without going through
// `setPoaDeclared`. Without this, PoA details could be saved with the
// delegation paper silently unowed. Safe against a genuine "no": answering
// it clears these keys, so they cannot outlive the answer.
return POA_KEYS.some((k) => (company.attributes?.[k] as string | undefined)?.trim())
? "yes"
: null;
}
/**
* Do two people's names refer to the same person, as far as a string can tell?
*
* Deliberately loose: eTrade returns uppercase Latin transliterations of
* Amharic names and Fayda returns its own, so exact equality would flag almost
* every company. Case, punctuation, extra whitespace and word ORDER are all
* ignored — "ABEBE KEBEDE TESFA" and "Tesfa, Abebe Kebede" match. Anything
* beyond that is the reviewer's call, which is why the verdict is advisory.
*/
export function ownerNameMatchesEtrade(
ownerName: string | null | undefined,
etradeName: string | null | undefined,
): boolean | null {
const words = (v: string | null | undefined) =>
(v ?? "")
.toLowerCase()
.replace(/[^a-z0-9-፿\s]/g, " ")
.split(/\s+/)
.filter(Boolean)
.sort();
const a = words(ownerName);
const b = words(etradeName);
if (a.length === 0 || b.length === 0) return null;
return a.length === b.length && a.every((w, i) => w === b[i]);
}
/**
* Derive the company's identity state from its row.
*
* Pure and shared: `CompaniesService` gates on it, `ProfileResponseDto` and the
* backoffice's company DTO render from it, so the settings page, the onboarding
* wizard and the reviewer can never disagree with the rule the API enforces.
*/
export function buildCompanyIdentityState(
company: Company,
): CompanyIdentityStateDto {
const attrs = company.attributes ?? {};
const read = (key: string) => (attrs[key] as string | undefined) ?? null;
// Fayda is an Ethiopian national ID — a foreign company's owner may not hold
// one, so a typed passport number is the mandatory credential there instead.
// The two are mutually exclusive by nationality but independently tracked,
// since a foreign owner verifying with Fayda doesn't waive the passport.
const foreign = company.nationality === CompanyNationality.Foreign;
const faydaRequired = !foreign;
const passportRequired = foreign;
// Fayda is an Ethiopian national ID. A foreign company's people may hold
// none, so a typed passport number stands in — either one proves the person,
// and holding both is fine.
const passportAccepted = company.nationality === CompanyNationality.Foreign;
const owner: OwnerIdentityStateDto = {
...stateFor(attrs, "owner"),
passportNumber: read("ownerPassportNumber"),
};
const owner = stateFor(attrs, "owner");
const poa = stateFor(attrs, "poa");
const poaDue =
(company.companyProfiles ?? []).some(
(p) => p.type === ProfileType.freightForwarder,
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
const poaDeclared = readPoaDeclaration(company);
const subject: IdentitySubject | null =
poaDeclared === "yes" ? "poa" : poaDeclared === "no" ? "owner" : null;
const gm = stateFor(attrs, "gm");
const gmSameAsOwner = Boolean(attrs.gmSameAsOwner);
const poaSameAsOwner = Boolean(attrs.poaSameAsOwner);
const proven = (s: IdentityVerificationStateDto) =>
s.verified || (passportAccepted && Boolean(s.passportNumber?.trim()));
const ownerProven = faydaRequired
? owner.verified
: !passportRequired || Boolean(owner.passportNumber);
const identityProven =
subject === null ? false : proven(subject === "poa" ? poa : owner);
// Fayda is an Ethiopian national ID, so only an Ethiopian company's
// personnel can be held to it. A foreign company may nominate a
// representative who holds one — and is offered the verification — but a
// typed name has to remain sufficient, or a foreign company whose PoA has no
// Fayda ID could never finish onboarding.
const poaProven = faydaRequired
? poa.verified
: poa.verified || Boolean(poa.name?.trim());
// The GM is deliberately absent from this verdict: it names who to talk to,
// not what the company may do, and it has never gated trading. Capturing it
// through Fayda changes how it is collected, not whether it is required.
const complete = ownerProven && (!poaDue || poaProven);
const etradeManagerName =
(attrs[ETRADE_MANAGER_NAME_KEY] as string | undefined) ?? null;
const etradeManagerPhone =
(attrs[ETRADE_MANAGER_PHONE_KEY] as string | undefined) ?? null;
return {
faydaRequired,
passportRequired,
passportAccepted,
poaDeclared,
subject,
owner,
poa,
poaSameAsOwner,
gm,
gmSameAsOwner,
complete,
identityProven,
etradeManagerName,
etradeManagerPhone,
ownerMatchesEtrade: ownerNameMatchesEtrade(owner.name, etradeManagerName),
complete: subject !== null && identityProven,
};
}

View File

@@ -8,7 +8,10 @@
* truth the wizard uses to auto-finish.
*/
import { CompanyIdentityStateDto } from "./complete-identity-verification.dto";
import {
CompanyIdentityStateDto,
PoaDeclaration,
} from "./complete-identity-verification.dto";
export interface OnboardingInfoField {
key: string;
@@ -38,15 +41,19 @@ export interface OnboardingLicenseProfile {
}
export interface OnboardingPoaState {
/** True when the company operates as a freight forwarder — PoA is mandatory. */
required: boolean;
/** True once any PoA detail has been entered. */
provided: boolean;
/**
* True when the DARS delegation paper is owed — a PoA exists (or is
* mandatory) and is not the owner themselves. An owner representing their own
* company delegates to nobody, so there is no delegation to evidence.
* True when the company operates as a freight forwarder: it signs on other
* companies' behalf, so a Power of Attorney is non-negotiable and the portal
* renders the question answered and locked rather than asking it.
*/
locked: boolean;
/**
* The company's answer to "does anyone hold power of attorney for you?".
* Null until it answers — which is itself outstanding, since the answer
* decides whose identity is verified.
*/
declared: PoaDeclaration | null;
/** True when the DARS delegation paper is owed — i.e. `declared === "yes"`. */
delegationLetterRequired: boolean;
/** True when the DARS delegation paper is stored for the company. */
delegationLetterUploaded: boolean;
@@ -59,9 +66,17 @@ export interface OnboardingPoaState {
}
export class OnboardingRequirementsResponseDto {
/** Resolved document setting code (by nationality) the docs were drawn from. */
/**
* Resolved document setting code the docs were drawn from: the company's
* nationality set, or the co-operative set in its place.
*/
documentSettingCode: string;
nationality: string;
/**
* The company trades as a co-operative: no business licence, so no eTrade
* lookup, no per-role licence upload, and no freight-forwarder role.
*/
cooperative: boolean;
/** Required company-information fields and whether each is filled. */
companyInfo: {
@@ -100,6 +115,7 @@ export class OnboardingRequirementsResponseDto {
constructor(init: Omit<OnboardingRequirementsResponseDto, never>) {
this.documentSettingCode = init.documentSettingCode;
this.nationality = init.nationality;
this.cooperative = init.cooperative;
this.companyInfo = init.companyInfo;
this.documents = init.documents;
this.licenseProfiles = init.licenseProfiles;

View File

@@ -2,7 +2,7 @@ import {
buildCompanyIdentityState,
CompanyIdentityStateDto,
} from "./complete-identity-verification.dto";
import { Company } from "../entities/company.entity";
import { Company, isCooperative } from "../entities/company.entity";
import { ExternalProfile } from "../entities/external-profile.entity";
import {
ChangeRequestStatus,
@@ -15,6 +15,12 @@ export class ProfileResponseDto {
companyName: string;
companyType: string;
nationality: string | null;
/**
* The company trades as a co-operative: it has a TIN but no business licence,
* so the company step collects the registration by hand instead of fetching
* it from eTrade.
*/
cooperative: boolean;
companyLocation: string;
companyAddress: string | null;
tinNumber: string;
@@ -42,9 +48,10 @@ export class ProfileResponseDto {
contactPersonPhone: string | null;
/** Phone that passed SMS OTP verification (drives the verify-step resume). */
contactVerifiedPhone: string | null;
generalManagerName: string | null;
generalManagerEmail: string | null;
generalManagerPhone: string | null;
/** The owner — whoever the eTrade licence names as the business's manager. */
ownerName: string | null;
ownerEmail: string | null;
ownerPhone: string | null;
poaName: string | null;
poaPhone: string | null;
@@ -55,12 +62,13 @@ export class ProfileResponseDto {
profileId: string;
/**
* Fayda verification state for the company's owner and PoA — not the general
* manager, which is a separate typed role. The settings tabs and the
* onboarding wizard render from `identity.faydaRequired` /
* `identity.passportRequired`: an Ethiopian company verifies the owner (and
* PoA) instead of typing their details; a foreign one requires a typed
* passport number instead.
* The company's single identity verification, plus who it belongs to.
*
* `identity.subject` follows the company's PoA declaration — the
* representative when one is named, otherwise the owner. The settings tabs
* and the onboarding wizard render from it: `passportAccepted` says whether a
* typed passport number is an alternative to Fayda (foreign companies only),
* and `ownerMatchesEtrade` is the check the backoffice makes.
*/
identity: CompanyIdentityStateDto;
@@ -84,6 +92,7 @@ export class ProfileResponseDto {
this.companyName = company.name;
this.companyType = company.type;
this.nationality = company.nationality ?? null;
this.cooperative = isCooperative(company);
this.companyProfiles =
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
[];
@@ -113,9 +122,9 @@ export class ProfileResponseDto {
this.contactPersonEmail = attrs.contactPersonEmail ?? null;
this.contactPersonPhone = attrs.contactPersonPhone ?? null;
this.contactVerifiedPhone = attrs.contactVerifiedPhone ?? null;
this.generalManagerName = attrs.generalManagerName ?? null;
this.generalManagerEmail = attrs.generalManagerEmail ?? null;
this.generalManagerPhone = attrs.generalManagerPhone ?? null;
this.ownerName = attrs.ownerName ?? null;
this.ownerEmail = attrs.ownerEmail ?? null;
this.ownerPhone = attrs.ownerPhone ?? null;
this.poaName = attrs.poaName ?? null;
this.poaPhone = attrs.poaPhone ?? null;
this.poaEmail = attrs.poaEmail ?? null;

View File

@@ -3,6 +3,7 @@ import {
CompanyType,
CompanyStatus,
CompanyNationality,
isCooperative,
} from '../entities/company.entity';
import {
CompanyProfile,
@@ -55,6 +56,12 @@ export class ResponseCompanyDto {
type: CompanyType;
status: CompanyStatus;
nationality?: CompanyNationality | null;
/**
* The company trades as a co-operative: no business licence, so its
* registration was typed rather than fetched from eTrade and there is no
* eTrade manager to check the owner against.
*/
cooperative: boolean;
tin: string;
vatNumber?: string | null;
fanNumber?: string | null;
@@ -89,9 +96,14 @@ export class ResponseCompanyDto {
houseNo?: string | null;
/**
* Owner/PoA Fayda verification state, shared with the portal
* (`buildCompanyIdentityState`) so backoffice never re-derives — or
* disagrees with — the rule the API actually enforces.
* The company's single identity verification, shared with the portal
* (`buildCompanyIdentityState`) so backoffice never re-derives — or disagrees
* with — the rule the API actually enforces.
*
* `subject` names whose verification it is (the PoA when one is declared,
* otherwise the owner), and `ownerMatchesEtrade` is the reviewer's check:
* does the owner the company put forward match the manager on the eTrade
* licence? Advisory — see the note on that field.
*/
identity: CompanyIdentityStateDto;
@@ -105,6 +117,7 @@ export class ResponseCompanyDto {
this.type = company.type;
this.status = company.status;
this.nationality = company.nationality ?? null;
this.cooperative = isCooperative(company);
this.tin = company.tin;
this.vatNumber = company.vatNumber;
this.fanNumber = company.fanNumber;

View File

@@ -0,0 +1,17 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsIn } from "class-validator";
import {
POA_DECLARATIONS,
PoaDeclaration,
} from "./complete-identity-verification.dto";
export class SetPoaDeclaredDto {
@ApiProperty({
enum: POA_DECLARATIONS,
description:
'Whether anyone holds power of attorney for this company. "no" tears down any representative already recorded.',
})
@IsIn(POA_DECLARATIONS)
declared!: PoaDeclaration;
}

View File

@@ -1,4 +1,10 @@
import { ArrayMinSize, IsArray, IsEnum, IsOptional } from "class-validator";
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsEnum,
IsOptional,
} from "class-validator";
import { CompanyNationality, CompanyType } from "../entities/company.entity";
import { ProfileType } from "../entities/company-profile.entity";
@@ -14,4 +20,15 @@ export class StartOnboardingDto {
@IsOptional()
@IsEnum(CompanyNationality)
nationality?: CompanyNationality;
/**
* The company trades as a co-operative: it holds a TIN but no business
* licence, so there is no eTrade record to fetch its registration from.
* Chosen on the same step as the nationality and the roles, because it
* decides all three of what the next step asks for, which documents apply,
* and which roles are even available (a co-op cannot freight-forward).
*/
@IsOptional()
@IsBoolean()
cooperative?: boolean;
}

View File

@@ -5,7 +5,6 @@ import {
MaxLength,
IsEnum,
IsIn,
Matches,
} from "class-validator";
import { ETHIOPIAN_REGIONS, type EthiopianRegion } from "@edr/types";
import { CompanyNationality } from "../entities/company.entity";
@@ -36,20 +35,21 @@ export class UpdateProfileDto {
@IsTin({ message: "TIN must be exactly 10 digits" })
tin?: string;
// Ethiopian VAT registration numbers are 10 digits (the same shape as the
// TIN), but some are issued with an 11th. Both portal forms enforce the same
// range; without it here the API happily stored whatever a stale client sent,
// and the two layers disagreed about what the column may hold.
// No shape check. Ethiopian VAT numbers are usually 10 or 11 digits, but a
// foreign company's is whatever its own tax authority issues — letters,
// dashes and any length — and a co-operative's registration numbering does
// not follow the trade-licence pattern either. The field is required (the
// portal enforces non-blank) but its content is not ours to police.
@IsOptional()
@IsString()
@Matches(/^\d{10,11}$/, { message: "VAT number must be 10 or 11 digits" })
@MaxLength(64)
vatNumber?: string;
// `fanNumber` is deliberately absent: the FAN is the Fayda number of the
// company's PoA (or its general manager), so it is derived from a completed
// Fayda verification rather than typed. The global validation pipe runs with
// forbidNonWhitelisted, so a client that still sends it gets a 400 telling it
// so — see CompaniesService.completeIdentityVerification.
// `fanNumber` is deliberately absent: the FAN is a Fayda number, so it would
// have to come from a completed verification rather than be typed — and
// Fayda's userinfo carries no national ID number, so nothing produces one.
// The global validation pipe runs with forbidNonWhitelisted, so a client that
// still sends it gets a 400 telling it so.
@IsOptional()
@IsString()
@@ -78,18 +78,31 @@ export class UpdateProfileDto {
@IsValidPhone()
contactVerifiedPhone?: string;
/**
* The owner — whoever the eTrade licence names as the business's manager.
*
* All three are required before onboarding can be submitted, whatever their
* source: the eTrade lookup prefills the name and phone, a Fayda
* verification can supply all three, and the portal renders an input for
* whatever neither did (eTrade returns no email at all, and Fayda's email
* claim is optional, so that one is usually typed).
*
* Locked once a Fayda verification supplied them — see
* `IDENTITY_OWNED_FIELDS` — but only field by field: a claim that came back
* empty owns nothing and stays typeable.
*/
@IsOptional()
@IsString()
generalManagerName?: string;
ownerName?: string;
@IsOptional()
@IsEmail()
generalManagerEmail?: string;
ownerEmail?: string;
@IsOptional()
@IsString()
@IsValidPhone()
generalManagerPhone?: string;
ownerPhone?: string;
@IsOptional()
@IsString()
@@ -113,15 +126,22 @@ export class UpdateProfileDto {
poaAddress?: string;
/**
* The owner's passport number — the identity credential for a foreign
* company, since Fayda is an Ethiopian national ID. Plain typed field, never
* written or locked by a Fayda verification: still required even if the
* owner also verifies.
* Passport numbers — the alternative identity credential for a foreign
* company, since Fayda is an Ethiopian national ID. Plain typed fields, never
* written or locked by a Fayda verification.
*
* Only the one belonging to the company's declared identity subject matters:
* the PoA's when a representative is named, the owner's otherwise. An
* Ethiopian company is not offered either — it must use Fayda.
*/
@IsOptional()
@IsString()
ownerPassportNumber?: string;
@IsOptional()
@IsString()
poaPassportNumber?: string;
@IsOptional()
@IsString()
@MaxLength(100)

View File

@@ -32,6 +32,25 @@ export enum CompanyNationality {
Foreign = "foreign",
}
/**
* `attributes` key marking a co-operative union or farm.
*
* Such a company has a TIN but no business licence, so there is no eTrade record to
* look its registration up in — the company name, registered address and the
* owner are all typed instead of fetched, and the eTrade authenticity check is
* skipped rather than failed. It is a flag rather than a column because
* everything it changes is behavioural (which lookup runs, which documents
* apply, which roles are offered); nothing queries or joins on it.
*/
export const COOPERATIVE_KEY = "cooperative";
/** Is this a co-operative union or farm (a TIN, but no business licence)? */
export function isCooperative(
company: Pick<Company, "attributes"> | null | undefined,
): boolean {
return company?.attributes?.[COOPERATIVE_KEY] === true;
}
@Entity({ schema: "freight", name: "companies" })
@Index(["tin"])
@Index(["type"])
@@ -112,29 +131,11 @@ export class Company extends BaseEntity {
})
contactPersonPhone?: string | null;
@Column({
name: "general_manager_name",
type: "varchar",
length: 100,
nullable: true,
})
generalManagerName?: string | null;
@Column({
name: "general_manager_email",
type: "varchar",
length: 150,
nullable: true,
})
generalManagerEmail?: string | null;
@Column({
name: "general_manager_phone",
type: "varchar",
length: 20,
nullable: true,
})
generalManagerPhone?: string | null;
// The general manager used to live here as three columns. It named who to
// talk to, gated nothing, and nothing ever populated the columns — the write
// path put the values in `attributes`. Removed in RemoveGeneralManager; the
// company's people are now its owner (whoever the eTrade licence names) and
// its Power of Attorney, both in `attributes`.
@Column({ name: "website", type: "varchar", length: 200, nullable: true })
website?: string | null;

View File

@@ -63,6 +63,21 @@ describe('ETradeService business selection', () => {
expect(fetched).toEqual(['MT/AA/14/670/11551235/2017']);
});
it('survives the null entries eTrade puts in SubGroups', () => {
const { service } = build();
const info = companyInfo();
(info.Businesses[0] as any).SubGroups = [
null,
{ Code: 66331, Description: 'Export trade in minerals' },
{ Code: 1, Description: null },
];
const data = service.extractRegistrationData(
{ LicenceNumber: 'x' } as ETradeBusinessInfo,
info,
);
expect(data.businesses?.[0].activity).toBe('Export trade in minerals');
});
it('lists every licence for the picker, code prefixes stripped', () => {
const { service } = build();
const data = service.extractRegistrationData(

View File

@@ -142,7 +142,8 @@ export class ETradeService {
tradeName: b.TradesName?.trim() || "",
activity: (b.SubGroups ?? [])
// Some descriptions repeat the code inline ("(65611)Import trade …").
.map((g) => g.Description?.replace(/^\(\d+\)\s*/, "").trim())
// eTrade also puts null entries in this array, so every hop is optional.
.map((g) => g?.Description?.replace(/^\(\d+\)\s*/, "").trim())
.filter(Boolean)
.join(", "),
renewedTo: b.RenewedTo || "",

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,152 @@
import { BadRequestException } from '@nestjs/common';
import { ContractTransitionService } from './contract-transition.service';
/**
* Where the booking-contract view reads the global stamp live, the contracts
* path SNAPSHOTS it onto the signature row at signing time, so replacing the
* company stamp can never restamp an already-executed contract. These specs
* pin the sourcing split: EDR always seals with the global stamp and staff
* never supply one, while the customer must upload their own.
*/
describe('applySignature stamp sourcing', () => {
const contract = { id: 'c-1', reference: 'CTR-1', status: 'SIGNED_CUSTOMER' };
const GLOBAL_STAMP = 'data:image/png;base64,RURS';
const build = (globalStamp: string | null = GLOBAL_STAMP) => {
const uploads: Array<{ code: string; image: string }> = [];
const saved: unknown[] = [];
const service = Object.create(
ContractTransitionService.prototype,
) as ContractTransitionService;
Object.assign(service, {
logger: { warn: jest.fn(), log: jest.fn() },
stampSettings: {
getStampImageUrl: jest.fn().mockResolvedValue(globalStamp),
},
contractsRepository: {
saveSignature: jest.fn((row: unknown) => {
saved.push(row);
return Promise.resolve(undefined);
}),
},
signaturesService: {
getForUser: jest.fn().mockResolvedValue(null),
upsertForUser: jest.fn().mockResolvedValue(undefined),
},
uploadSignatureAsset: jest.fn((_c: unknown, code: string, image: string) => {
uploads.push({ code, image });
return Promise.resolve({ id: `file-${code}` });
}),
});
return { service, uploads, saved };
};
const apply = (
service: ContractTransitionService,
dto: Record<string, unknown>,
) =>
(
service as unknown as {
applySignature(
c: unknown,
d: unknown,
o: { signerUserId?: string },
): Promise<void>;
}
).applySignature(contract, dto, { signerUserId: 'u-1' });
const staffDto = {
role: 'STAFF' as const,
signerDisplayName: 'E. Staff',
signatureImageBase64: 'data:image/png;base64,U0lH',
};
it('seals the EDR side with the global stamp', async () => {
const { service, uploads, saved } = build();
await apply(service, staffDto);
expect(uploads).toContainEqual({ code: 'stamp_staff', image: GLOBAL_STAMP });
expect(saved[0]).toEqual(
expect.objectContaining({ stampFileId: 'file-stamp_staff' }),
);
});
it('ignores a stamp a staff client tries to supply', async () => {
const { service, uploads } = build();
await apply(service, {
...staffDto,
stampImageBase64: 'data:image/png;base64,SEFDSw==',
});
expect(uploads).toContainEqual({ code: 'stamp_staff', image: GLOBAL_STAMP });
expect(uploads.map((u) => u.image)).not.toContain(
'data:image/png;base64,SEFDSw==',
);
});
/**
* Failing loudly matters here: getStampImageUrl degrades to null when the
* stamp cannot be inlined, and silently executing an unsealed contract would
* be worse than refusing to counter-sign.
*/
it('refuses to counter-sign when no global stamp is configured', async () => {
const { service, saved } = build(null);
await expect(apply(service, staffDto)).rejects.toBeInstanceOf(
BadRequestException,
);
await expect(apply(service, staffDto)).rejects.toThrow(/company stamp is configured/i);
expect(saved).toHaveLength(0);
});
it('requires the customer to upload their own stamp', async () => {
const { service, saved } = build();
await expect(
apply(service, {
role: 'CUSTOMER',
signerDisplayName: 'C. Customer',
signatureImageBase64: 'data:image/png;base64,U0lH',
}),
).rejects.toThrow(/company stamp is required/i);
expect(saved).toHaveLength(0);
});
it('snapshots the customer stamp and never substitutes the global one', async () => {
const { service, uploads } = build();
const customerStamp = 'data:image/png;base64,Q1VTVA==';
await apply(service, {
role: 'CUSTOMER',
signerDisplayName: 'C. Customer',
signatureImageBase64: 'data:image/png;base64,U0lH',
stampImageBase64: customerStamp,
});
expect(uploads).toContainEqual({
code: 'stamp_customer',
image: customerStamp,
});
expect(uploads.map((u) => u.image)).not.toContain(GLOBAL_STAMP);
});
/**
* DIRECTOR/CEO rows are internal approval signatures, not party seals, so
* they are deliberately exempt from the stamp requirement.
*/
it('lets internal approval signatures through without any stamp', async () => {
const { service, uploads, saved } = build();
await apply(service, {
role: 'DIRECTOR',
signerDisplayName: 'D. Director',
signatureImageBase64: 'data:image/png;base64,U0lH',
});
expect(uploads.map((u) => u.code)).toEqual(['signature_director']);
expect(saved[0]).toEqual(expect.objectContaining({ stampFileId: null }));
});
});

View File

@@ -9,7 +9,7 @@ import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { randomUUID } from 'node:crypto';
import { Readable } from 'stream';
import { insertWithGeneratedReference } from '@edr/api-common';
import { insertWithGeneratedReference, logCtx } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder';
@@ -35,6 +35,7 @@ import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
import { FilesService } from '../files/files.service';
import { SignaturesService } from '../signatures/signatures.service';
import { StampSettingsService } from '../stamp-settings/stamp-settings.service';
import { OtpService } from '../otp/otp.service';
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
import { ContractPricingService } from './contract-pricing.service';
@@ -175,6 +176,7 @@ export class ContractTransitionService {
private readonly otpService: OtpService,
private readonly notifier: ContractNotifierService,
private readonly contractTemplates: ContractTemplatesService,
private readonly stampSettings: StampSettingsService,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
@@ -1082,6 +1084,19 @@ export class ContractTransitionService {
): Promise<void> {
const role = dto.role as ContractSignerRole;
// Both sign() and counterSign() land here — who signed what, and whether the
// ink came from the request or the signer's saved profile signature.
logCtx(
{
contractId: contract.id,
reference: contract.reference,
role,
signerUserId: options.signerUserId,
usedDrawnImage: Boolean(dto.signatureImageBase64),
},
{ path: "contractSignatures", mode: "push" },
);
// Resolve the signature image. The client may send a freshly-drawn image, or
// omit it to reuse the signer's saved profile signature. Fall back to the
// saved one whenever no image is supplied.
@@ -1103,23 +1118,39 @@ export class ContractTransitionService {
// The company stamp is a separate image from the drawn signature. Both
// parties to the contract (client + EDR) must seal it; DIRECTOR/CEO rows
// are internal approval signatures, not party seals, so they stay exempt.
const stampRequired = role === 'CUSTOMER' || role === 'STAFF';
if (stampRequired && !dto.stampImageBase64) {
//
// The two parties source their seal differently: the customer uploads their
// own company stamp, while EDR always seals with the ONE global stamp
// (StampSettingsService) — staff never upload or pick a stamp.
if (role === 'CUSTOMER' && !dto.stampImageBase64) {
throw new BadRequestException(
'A company stamp is required to sign this contract.',
);
}
// Snapshot whichever stamp applies onto the signature row rather than
// referencing the global one, so replacing the company stamp later can
// never restamp an already-executed contract.
let stampImageBase64 = dto.stampImageBase64 ?? null;
if (role === 'STAFF') {
stampImageBase64 = await this.stampSettings.getStampImageUrl();
if (!stampImageBase64) {
throw new BadRequestException(
'No company stamp is configured. Upload the company stamp under Settings before counter-signing contracts.',
);
}
}
const fileRecord = await this.uploadSignatureAsset(
contract,
`signature_${role.toLowerCase()}`,
imageBase64,
);
const stampRecord = dto.stampImageBase64
const stampRecord = stampImageBase64
? await this.uploadSignatureAsset(
contract,
`stamp_${role.toLowerCase()}`,
dto.stampImageBase64,
stampImageBase64,
)
: null;

View File

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

View File

@@ -7,7 +7,7 @@ import {
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { insertWithGeneratedReference } from '@edr/api-common';
import { insertWithGeneratedReference, logCtx } from '@edr/api-common';
import { YardCountry } from '@edr/types';
//
@@ -811,6 +811,18 @@ export class ContractsService {
throw new NotFoundException(`Contract ${id} not found`);
}
// Entry state for every contract flow (submit, approve, sign, suspend…) —
// see the equivalent in BookingsService.findById.
logCtx(
{
id: contract.id,
reference: contract.reference,
statusAtEntry: contract.status,
companyId: contract.companyId,
},
{ path: "contract" },
);
if (contract.files && contract.files.length > 0) {
contract.files = await Promise.all(
contract.files.map(async (file: FileRecord) => {

View File

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

View File

@@ -26,6 +26,15 @@ export const POA_DELEGATION_LABEL = "DARS Delegation Paper";
/** Prefix of the setting codes the field is injected into. */
export const COMPANY_ONBOARDING_CODE_PREFIX = "company_onboarding_documents_";
/**
* The co-operative onboarding set — the third alternative to `_ethiopian` and
* `_foreign`, not an addition to them: a union or farm resolves to this set
* INSTEAD of its nationality's, because it holds no business licence and so
* owes a different list of papers. The delegation paper is injected into it
* like any other company onboarding set.
*/
export const COOPERATIVE_ONBOARDING_CODE = `${COMPANY_ONBOARDING_CODE_PREFIX}cooperative`;
const POA_DELEGATION_HELP =
"Delegation paper issued by the Documents Authentication and Registration " +
"Service (DARS) delegating the representative named above. Upload the " +

View File

@@ -12,8 +12,8 @@ import { DataSource, EntityManager } from "typeorm";
* `companies.contact_person_phone` is deliberately NOT consulted: the live write
* path stores that value in the `attributes` jsonb and has never populated the
* column, so every reader of it was silently falling through to `phone` anyway.
* `companies.general_manager_email` is the same trap on the email side — see
* {@link companyNotifyEmailExpr}.
* The retired `general_manager_email` column was the same trap on the email
* side — see {@link companyNotifyEmailExpr}.
*/
/**
@@ -50,24 +50,30 @@ export function companyNotifyPhoneExpr(alias: string): string {
* SQL expression for the company's notification address, given the joined `pc`
* alias.
*
* `companies.email` alone is not enough: it is written from ONE place — a
* Fayda-verified owner's email claim — so a foreign company, whose owner proves
* identity by passport instead, never gets one. Readers papered over that with
* `COALESCE(email, general_manager_email)`, but that column has the same problem
* `contact_person_phone` has above: onboarding writes the value into the
* `attributes` jsonb and nothing has ever populated the column, so the fallback
* could not fire and the mail was dropped in silence.
* `companies.email` is now the owner's email, written on every profile save
* whether or not the owner verified with Fayda — and the owner's email is a
* required onboarding field, so a company that finished onboarding has one.
* (It used to be written ONLY for a Fayda-verified owner, which meant every
* foreign company had none; the gap was papered over with a
* `general_manager_email` leg that could never fire, because onboarding wrote
* that value into `attributes` and nothing ever populated the column.)
*
* So: the company address, then the two the customer actually filled in during
* onboarding, then the account that registered them — which always has one,
* signup requires it. `NULLIF` because a blank jsonb key is not an address and
* `COALESCE` would happily stop on it.
* The `generalManagerEmail` attribute is still consulted, after the contact
* person: the general manager was removed, but companies onboarded before that
* may carry an address there and nowhere else. RemoveGeneralManager backfills
* `companies.email` from it, so this is belt-and-braces for rows that migration
* could not resolve.
*
* `NULLIF` because a blank jsonb key is not an address and `COALESCE` would
* happily stop on it. The account that registered the company is the last
* resort — signup guarantees it has one.
*/
export function companyNotifyEmailExpr(alias: string): string {
return `COALESCE(
NULLIF(${alias}.email, ''),
NULLIF(${alias}.attributes->>'generalManagerEmail', ''),
NULLIF(${alias}.attributes->>'ownerEmail', ''),
NULLIF(${alias}.attributes->>'contactPersonEmail', ''),
NULLIF(${alias}.attributes->>'generalManagerEmail', ''),
NULLIF(pc.email, '')
)`;
}

View File

@@ -1,6 +1,7 @@
// otp.service.ts
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { logCtx } from "@edr/api-common";
import { randomInt } from "node:crypto";
import { OtpRepository } from "./otp.repository";
@@ -317,6 +318,13 @@ export class OtpService {
}`;
if (result === "ok") this.logger.log(line);
else this.logger.warn(line);
// Outcome only — the target is a phone number / email address and stays out
// of the canonical line. Channels are safe and say which one was used.
logCtx(
{ mode, result, channels: channelsOf(target) },
{ path: "otp.verify", mode: "push" },
);
}
/**

View File

@@ -7,6 +7,7 @@ import {
import { HttpService } from "@nestjs/axios";
import { AxiosError } from "axios";
import { firstValueFrom } from "rxjs";
import { logCtx } from "@edr/api-common";
import {
InitiatePaymentRequest,
PaymentIntentSnapshot,
@@ -111,6 +112,16 @@ export class PaymentClientService {
body?: unknown,
): Promise<T> {
const url = `${this.baseUrl}${path}`;
// Every hop to the payment service lands on the request log line: which
// call, how slow, and what it answered. A settle that never happened is
// almost always one of these coming back 4xx/5xx or timing out.
const startedAt = Date.now();
const trace = (extra: Record<string, unknown>) =>
logCtx(
{ method, path, ms: Date.now() - startedAt, ...extra },
{ path: "outbound.payment", mode: "push" },
);
try {
const response = await firstValueFrom(
this.http.request<T>({
@@ -122,9 +133,11 @@ export class PaymentClientService {
: {},
}),
);
trace({ status: response.status });
return response.data;
} catch (err) {
if (err instanceof AxiosError && err.response) {
trace({ status: err.response.status });
if (err.response.status === 404) throw err;
const detail =
(err.response.data as { message?: string | string[] })?.message ??
@@ -136,6 +149,7 @@ export class PaymentClientService {
}
const message =
err instanceof Error && err.message ? err.message : String(err);
trace({ unreachable: true, error: message });
this.logger.error(
`payment service unreachable (${method} ${path}): ${message}`,
);

View File

@@ -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 { Public } from "@edr/api-common";
import {
@@ -14,11 +14,6 @@ import { PaymentService as PaymentSvc } from "./payment.service";
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()
export class PaymentEventsConsumer {
private readonly logger = new Logger(PaymentEventsConsumer.name);

View File

@@ -7,6 +7,7 @@ import {
Logger,
NotFoundException,
} from "@nestjs/common";
import { logCtx } from "@edr/api-common";
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
import { PaymentEntity } from "./entities/payment.entity";
import { PaymentRepository } from "./payment.repository";
@@ -214,8 +215,16 @@ export class PaymentService {
PaymentReferenceType.SHIPMENT,
referenceId,
);
logCtx(
{ referenceId, paid: result.paid, unverifiable: result.unverifiable },
{ path: "payment.reconcile" },
);
return { paid: result.paid, unverifiable: result.unverifiable };
} catch (err) {
logCtx(
{ referenceId, unverifiable: true, error: (err as Error).message },
{ path: "payment.reconcile" },
);
this.logger.warn(
`Reconcile for shipment ${referenceId} failed: ${(err as Error).message}`,
);
@@ -224,6 +233,18 @@ export class PaymentService {
}
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
logCtx(
{
referenceId: input.referenceId,
source: input.source,
orderRef: input.orderRef,
method: input.method,
amountMinor: input.amountMinor,
currency: input.currency,
platform: input.platform,
},
{ path: "payment.initiate" },
);
try {
const isCbeBill = input.method === ProviderMethod.CBE_BILL;
// CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8).
@@ -268,6 +289,17 @@ export class PaymentService {
const intent = await this.upsertIntent(input, snapshot);
logCtx(
{
intentId: intent.id,
providerStatus: snapshot.status,
providerTxnId: snapshot.providerTxnId,
merchantOrderId: snapshot.merchantOrderId,
immediateSuccess,
},
{ path: "payment.initiate" },
);
if (immediateSuccess) {
// Settle the projection but DO NOT notify billing — billing settles
// inline once it has stored intentId on the invoice (see payInvoice),
@@ -428,6 +460,17 @@ export class PaymentService {
otp,
);
logCtx(
{
intentId: local.id,
refId: local.refId,
gatewayIntentId: snapshot.intentId,
providerStatus: confirmed.status,
failureCode: confirmed.failureCode,
},
{ path: "payment.confirmOtp" },
);
if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) {
await this.markIntentSucceeded(local.id, {
providerTxnId: confirmed.providerTxnId,
@@ -460,6 +503,16 @@ export class PaymentService {
): Promise<{ alreadyFinalized: boolean }> {
const intent = await this.paymentRepo.findOneBy({ id: intentId });
if (!intent) throw new NotFoundException("PaymentIntent not found");
logCtx(
{
intentId,
refId: intent.refId,
priorStatus: intent.status,
providerTxnId: opts.providerTxnId,
notifyBilling: opts.notify !== false,
},
{ path: "payment.settle" },
);
if (intent.status === "success") {
// Still notify billing: a prior delivery may have flipped the intent to
// success and then died before the invoice settled (the two steps are not
@@ -471,6 +524,7 @@ export class PaymentService {
opts.paidAt ?? intent.paidAt ?? undefined,
);
}
logCtx(true, { path: "payment.settle.alreadyFinalized", mode: "set" });
return { alreadyFinalized: true };
}
@@ -507,6 +561,15 @@ export class PaymentService {
referenceId: string,
): Promise<{ acknowledged: boolean }> {
const intent = await this.paymentRepo.findOneBy({ refId: referenceId });
logCtx(
{
referenceId,
intentId: intent?.id,
intentStatus: intent?.status ?? "none",
method: intent?.method,
},
{ path: "payment.successRedirect" },
);
if (!intent || intent.method === "cbe-bill") {
return { acknowledged: false };
}
@@ -533,6 +596,16 @@ export class PaymentService {
}): Promise<void> {
const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
if (!intent) throw new NotFoundException("PaymentIntent not found");
logCtx(
{
intentId: intent.id,
refId: intent.refId,
priorStatus: intent.status,
failureCode: input.failureCode,
failureMessage: input.failureMessage,
},
{ path: "payment.failed" },
);
if (intent.status === "success" || intent.status === "canceled") return;
await this.paymentRepo.update(

View File

@@ -91,9 +91,8 @@ export class CargoTypesRepository implements ICargoTypesRepository {
/**
* Diffs the wagon-type links through the relation query builder rather than
* an entity save: junction-row inserts from save() broadcast afterInsert with
* no entity attached, which the @tria-plc/auditlog subscriber (deployed
* builds) dereferences and crashes the request on.
* an entity save, so junction rows are written without broadcasting
* afterInsert events for entity-less inserts.
*/
private async syncWagonTypes(
id: string,

View File

@@ -0,0 +1,9 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsString, MinLength } from "class-validator";
export class UpdateStampSettingDto {
@ApiProperty({ description: "Stamp image as a base64 data URL (PNG/JPG)." })
@IsString()
@MinLength(1)
stampImageBase64!: string;
}

View File

@@ -0,0 +1,24 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, JoinColumn, ManyToOne } from "typeorm";
import { FileRecord } from "../../files/entities/file.entity";
/**
* Single-row table holding the one company stamp/seal image stamped onto
* generated invoice/receipt PDFs (see InvoiceDocumentService). Mirrors the
* exchange_settings single-row pattern — `get()` lazily creates the row, and
* there is never more than one.
*/
@Entity({ schema: "freight", name: "stamp_settings" })
export class StampSetting extends BaseEntity {
@Column({ name: "stamp_file_id", type: "uuid", nullable: true })
stampFileId?: string | null;
@ManyToOne(() => FileRecord, { nullable: true })
@JoinColumn({ name: "stamp_file_id" })
stampFile?: FileRecord | null;
/** IAM user id of the last operator to set/clear the stamp. */
@Column({ name: "updated_by_id", type: "uuid", nullable: true })
updatedById?: string | null;
}

View File

@@ -0,0 +1,39 @@
import { Body, Controller, Delete, Get, Put } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CurrentUser } from "@edr/api-common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { UpdateStampSettingDto } from "./dto/update-stamp-setting.dto";
import { StampSettingsService } from "./stamp-settings.service";
@ApiTags("stamp-settings")
@ApiBearerAuth()
@Controller("stamp-settings")
export class StampSettingsController {
constructor(private readonly service: StampSettingsService) {}
@Get()
@BookingStaff([FREIGHT_PERMS.settings.stamp.view, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Current company stamp used on invoice/receipt PDFs" })
get() {
return this.service.getView();
}
@Put()
@BookingStaff([FREIGHT_PERMS.settings.stamp.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Replace the company stamp" })
update(@Body() dto: UpdateStampSettingDto, @CurrentUser() user: TCurrentUser) {
return this.service.setStamp(dto.stampImageBase64, user?.id ?? null);
}
@Delete()
@BookingStaff([FREIGHT_PERMS.settings.stamp.manage, FREIGHT_PERMS.admin])
@ApiOperation({
summary: "Clear the company stamp (invoices fall back to the plain seal)",
})
clear(@CurrentUser() user: TCurrentUser) {
return this.service.clearStamp(user?.id ?? null);
}
}

View File

@@ -0,0 +1,23 @@
import { Global, Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { FilesModule } from "../files/files.module";
import { MinioModule } from "../minio/minio.module";
import { StampSetting } from "./entities/stamp-setting.entity";
import { StampSettingsController } from "./stamp-settings.controller";
import { StampSettingsRepository } from "./stamp-settings.repository";
import { StampSettingsService } from "./stamp-settings.service";
/**
* Global so DocumentsModule (invoice PDF rendering) can inject
* {@link StampSettingsService} without pulling in a circular billing/warehouse
* dependency — same reasoning as ExchangeSettingsModule.
*/
@Global()
@Module({
imports: [TypeOrmModule.forFeature([StampSetting]), FilesModule, MinioModule],
controllers: [StampSettingsController],
providers: [StampSettingsRepository, StampSettingsService],
exports: [StampSettingsService],
})
export class StampSettingsModule {}

View File

@@ -0,0 +1,21 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { BaseRepository } from "@edr/api-common";
import { StampSetting } from "./entities/stamp-setting.entity";
@Injectable()
export class StampSettingsRepository extends BaseRepository<StampSetting> {
constructor(
@InjectRepository(StampSetting)
repo: Repository<StampSetting>,
) {
super(repo);
}
/** The single settings row, with its stamp file joined, or null before first upload. */
findSingleton(): Promise<StampSetting | null> {
return this.repository.findOne({ where: {}, relations: ["stampFile"] });
}
}

View File

@@ -0,0 +1,110 @@
import { Readable } from "stream";
import { StampSettingsService } from "./stamp-settings.service";
/**
* The one global company stamp feeds three document paths (invoices, warehouse
* papers, contract signature blocks). All three treat the returned value as a
* `data:` URL — so the data-URL-or-null contract of getStampImageUrl is what
* these specs pin down, especially its behaviour when MinIO cannot be reached.
*/
describe("StampSettingsService.getStampImageUrl", () => {
const PNG = Buffer.from("fake-png-bytes");
const OBJECT_URL = "https://minio.local:9000/edr-freight/stamp/company.png";
const build = (
overrides: {
stampUrl?: string | null;
getFileStream?: jest.Mock;
findSingleton?: jest.Mock;
} = {},
) => {
const service = Object.create(
StampSettingsService.prototype,
) as StampSettingsService;
const warn = jest.fn();
Object.assign(service, {
logger: { warn, log: jest.fn() },
repository: {
findSingleton:
overrides.findSingleton ??
jest.fn().mockResolvedValue({
id: "s-1",
stampFileId: overrides.stampUrl ? "f-1" : null,
stampFile: overrides.stampUrl ? { url: overrides.stampUrl } : null,
updatedById: null,
updatedAt: null,
}),
create: jest.fn(),
update: jest.fn(),
},
minioService: {
getObjectNameFromUrl: jest.fn().mockReturnValue("stamp/company.png"),
getFileStream:
overrides.getFileStream ??
jest.fn().mockResolvedValue(Readable.from(PNG)),
},
filesService: { upload: jest.fn() },
});
return { service, warn };
};
it("inlines the stored stamp as a data URL", async () => {
const { service } = build({ stampUrl: OBJECT_URL });
await expect(service.getStampImageUrl()).resolves.toBe(
`data:image/png;base64,${PNG.toString("base64")}`,
);
});
it("returns null when no stamp is configured", async () => {
const { service } = build({ stampUrl: null });
await expect(service.getStampImageUrl()).resolves.toBeNull();
});
it("passes an already-inlined data URL straight through", async () => {
const dataUrl = "data:image/png;base64,QUJD";
const { service } = build({ stampUrl: dataUrl });
await expect(service.getStampImageUrl()).resolves.toBe(dataUrl);
});
/**
* Regression: inlineImageUrl falls back to the raw object URL when MinIO is
* unreachable, which is right for getView (a browser can fetch it) but wrong
* here. ContractTransitionService base64-decodes this value to snapshot the
* seal — and a URL decodes to garbage bytes WITHOUT throwing, so a transient
* MinIO failure used to seal an executed contract with a corrupt image file.
* Degrade to null instead so callers draw their text/vector seal.
*/
it("returns null rather than a raw object URL when MinIO inlining fails", async () => {
const { service, warn } = build({
stampUrl: OBJECT_URL,
getFileStream: jest.fn().mockRejectedValue(new Error("connect ECONNREFUSED")),
});
await expect(service.getStampImageUrl()).resolves.toBeNull();
expect(warn).toHaveBeenCalledWith(expect.stringMatching(/plain seal/i));
});
it("never throws when the settings lookup itself fails", async () => {
const { service, warn } = build({
findSingleton: jest.fn().mockRejectedValue(new Error("db is down")),
});
await expect(service.getStampImageUrl()).resolves.toBeNull();
expect(warn).toHaveBeenCalledWith(expect.stringContaining("db is down"));
});
it("still exposes the raw URL through getView, which a browser can load", async () => {
const { service } = build({
stampUrl: OBJECT_URL,
getFileStream: jest.fn().mockRejectedValue(new Error("connect ECONNREFUSED")),
});
await expect(service.getView()).resolves.toEqual(
expect.objectContaining({ stampImageUrl: OBJECT_URL }),
);
});
});

View File

@@ -0,0 +1,170 @@
import { Injectable, Logger } from "@nestjs/common";
import { Readable } from "stream";
import { DataSource } from "typeorm";
import { FilesService } from "../files/files.service";
import { FileRecord } from "../files/entities/file.entity";
import { MinioService } from "../minio/minio.service";
import { StampSettingsRepository } from "./stamp-settings.repository";
import { StampSetting } from "./entities/stamp-setting.entity";
export interface StampSettingView {
stampImageUrl: string | null;
updatedById: string | null;
updatedAt: Date | null;
}
/**
* Owns the single `stamp_settings` row: the one company stamp/seal image used
* on generated invoice/receipt PDFs (see InvoiceDocumentService). Same
* single-row shape as ExchangeSettingsService, but the value is an uploaded
* image (via FilesService) rather than a scalar.
*/
@Injectable()
export class StampSettingsService {
private readonly logger = new Logger(StampSettingsService.name);
constructor(
private readonly repository: StampSettingsRepository,
private readonly filesService: FilesService,
private readonly minioService: MinioService,
private readonly dataSource: DataSource,
) {}
/** The settings row, created empty on first access. */
async get(): Promise<StampSetting> {
const existing = await this.repository.findSingleton();
if (existing) return existing;
return this.repository.create({ stampFileId: null, updatedById: null });
}
/** Current stamp, with the image inlined as a data URL (or null if unset). */
async getView(): Promise<StampSettingView> {
const setting = await this.get();
return {
stampImageUrl: await this.inlineImageUrl(setting.stampFile?.url),
updatedById: setting.updatedById ?? null,
updatedAt: setting.updatedAt ?? null,
};
}
/**
* The stamp image for embedding into generated documents, ALWAYS as a
* `data:` URL or null. Never throws — document generation must succeed even
* if the stamp lookup fails; callers fall back to their own seal on null.
*
* The data-URL-or-null guarantee is load-bearing, not cosmetic. Callers do
* two things with this value that a bare MinIO URL silently corrupts:
* ContractTransitionService base64-decodes it to snapshot the seal onto a
* signature row (a URL decodes to garbage bytes, not an error, permanently
* sealing an executed contract with a broken image), and the HTML render
* path inlines it into an <img> that headless Chromium cannot fetch. So
* where getView() may hand a raw URL to a browser that can load it, this
* degrades to null and lets the caller draw its text/vector seal instead.
*/
async getStampImageUrl(): Promise<string | null> {
try {
const setting = await this.get();
const inlined = await this.inlineImageUrl(setting.stampFile?.url);
if (inlined && !inlined.startsWith("data:")) {
this.logger.warn(
`Company stamp could not be inlined for document rendering (falling back to the plain seal): ${inlined}`,
);
return null;
}
return inlined;
} catch (err) {
this.logger.warn(
`Could not load company stamp for PDF rendering: ${(err as Error).message}`,
);
return null;
}
}
/** Replace the stamp image, storing it in MinIO via FilesService. */
async setStamp(
stampImageBase64: string,
updatedById?: string | null,
): Promise<StampSettingView> {
const current = await this.get();
const previousFileId = current.stampFileId ?? null;
const fileRecord = await this.filesService.upload({
resourceId: current.id,
resource: "stamp_settings",
code: "stamp",
file: this.toUploadFile(stampImageBase64),
uploadedByUserId: updatedById ?? null,
});
await this.repository.update(current.id, {
stampFileId: fileRecord.id,
updatedById: updatedById ?? null,
});
if (previousFileId && previousFileId !== fileRecord.id) {
await this.dataSource.getRepository(FileRecord).delete(previousFileId);
}
this.logger.log(`Company stamp updated by ${updatedById ?? "unknown user"}`);
return this.getView();
}
/** Clear the stamp (invoices fall back to the programmatic seal). */
async clearStamp(updatedById?: string | null): Promise<StampSettingView> {
const current = await this.get();
const previousFileId = current.stampFileId ?? null;
await this.repository.update(current.id, {
stampFileId: null,
updatedById: updatedById ?? null,
});
if (previousFileId) {
await this.dataSource.getRepository(FileRecord).delete(previousFileId);
}
return this.getView();
}
private toUploadFile(base64: string): Express.Multer.File {
const raw = base64.includes(",") ? base64.split(",")[1]! : base64;
const buffer = Buffer.from(raw, "base64");
return {
fieldname: "stamp",
originalname: "company-stamp.png",
encoding: "7bit",
mimetype: "image/png",
size: buffer.length,
buffer,
stream: Readable.from(buffer),
destination: "",
filename: "",
path: "",
};
}
private async inlineImageUrl(url?: string | null): Promise<string | null> {
if (!url) return null;
if (url.startsWith("data:")) return url;
try {
const objectName = this.minioService.getObjectNameFromUrl(url);
const stream = await this.minioService.getFileStream(objectName);
const buffer = await this.streamToBuffer(stream);
return `data:image/png;base64,${buffer.toString("base64")}`;
} catch {
return url;
}
}
private streamToBuffer(stream: Readable): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on("data", (chunk: Buffer | string) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
stream.on("error", reject);
stream.on("end", () => resolve(Buffer.concat(chunks)));
});
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1646,4 +1646,92 @@ describe('TrainSchedulingService', () => {
).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 817 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();
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,6 +23,7 @@ import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
import { BuildTrainDto } from './dto/build-train.dto';
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
import { SendWagonToMaintenanceDto } from './dto/send-wagon-to-maintenance.dto';
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
import { UpdateTrainYardDto } from './dto/update-train-yard.dto';
@@ -135,8 +136,14 @@ export class TrainBuilderController {
@Param('id', ParseUUIDPipe) id: string,
@Param('wagonId', ParseUUIDPipe) wagonId: string,
@CurrentUser() user: AuthUserPayload,
@Body() dto?: SendWagonToMaintenanceDto,
) {
return this.trainBuilderService.sendWagonToMaintenance(id, wagonId, resolveAuthUserId(user));
return this.trainBuilderService.sendWagonToMaintenance(
id,
wagonId,
resolveAuthUserId(user),
dto?.note,
);
}
@Post(':id/reorder-wagons')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,6 +34,8 @@ import {
primaryContactUserJoin,
} from '../notifications/resolve-company-phone.util';
import { SignaturesService } from '../signatures/signatures.service';
import { StampSettingsService } from '../stamp-settings/stamp-settings.service';
import { sealClass, sealImageCss, sealMarkup } from '../billing/documents/seal-markup.util';
import { BulkInspectDto } from './dto/bulk-inspect.dto';
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
@@ -414,6 +416,7 @@ export class WarehouseInventoryService {
private readonly handover: HandoverService,
private readonly inbox: NotificationInboxService,
private readonly events: EventEmitter2,
private readonly stampSettings: StampSettingsService,
) {}
/**
@@ -3744,6 +3747,7 @@ export class WarehouseInventoryService {
const html = this.buildReleaseDocumentHtml({
reference,
issuedAt,
stampImageUrl: await this.stampSettings.getStampImageUrl(),
bookingReference,
bookingStatus: row?.bookingStatus ?? null,
customerName: row?.customerName ?? null,
@@ -4798,6 +4802,7 @@ export class WarehouseInventoryService {
const html = this.buildHandoverDocumentHtml({
reference,
handedOverAt,
stampImageUrl: await this.stampSettings.getStampImageUrl(),
bookingReference,
bookingStatus: row.bookingStatus ?? null,
customerName: row.customerName ?? null,
@@ -5603,6 +5608,8 @@ export class WarehouseInventoryService {
truckType?: string | null;
truckGateOut?: string | null;
truckWeightTons?: number | null;
/** The one global company stamp; null falls back to the drawn text seal. */
stampImageUrl?: string | null;
}): string {
const esc = (value: unknown) =>
String(value ?? '-')
@@ -5689,6 +5696,7 @@ export class WarehouseInventoryService {
.seal { width: 96px; height: 96px; margin: -28px auto 0; border: 3px double #17633a; border-radius: 999px; color: #17633a; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 14px; line-height: 1.05; transform: rotate(-17deg); text-transform: uppercase; }
.seal::before { content: ""; position: absolute; width: 78px; height: 78px; border: 1px solid #17633a; border-radius: 999px; }
.seal span { position: relative; }
${sealImageCss()}
</style>
</head>
<body>
@@ -5722,7 +5730,7 @@ export class WarehouseInventoryService {
</div>
<div class="signatures">
<div class="line">Officer in charge name / signature / date</div>
<div class="seal"><span>EDR<br />Warehouse<br />Cleared</span></div>
<div class="${sealClass(data.stampImageUrl)}">${sealMarkup(data.stampImageUrl, ['EDR', 'Warehouse', 'Cleared'])}</div>
<div class="line">Customer or driver name / signature / date</div>
</div>
</div>
@@ -5762,6 +5770,8 @@ export class WarehouseInventoryService {
signerDisplayName: string;
signatureImageUrl: string | null;
} | null;
/** The one global company stamp; null falls back to the drawn text seal. */
stampImageUrl?: string | null;
}): string {
const esc = (value: unknown) =>
String(value ?? '-')
@@ -5839,6 +5849,7 @@ export class WarehouseInventoryService {
.seal { position: relative; width: 96px; height: 96px; margin: -28px auto 0; border: 3px double #17633a; border-radius: 999px; color: #17633a; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 14px; line-height: 1.05; transform: rotate(-17deg); text-transform: uppercase; }
.seal::before { content: ""; position: absolute; width: 78px; height: 78px; border: 1px solid #17633a; border-radius: 999px; }
.seal span { position: relative; }
${sealImageCss()}
</style>
</head>
<body>
@@ -5882,7 +5893,7 @@ export class WarehouseInventoryService {
</div>
<div class="signatures">
<div class="line">Officer in charge name / signature / date</div>
<div class="seal"><span>EDR<br />Warehouse<br />Handover</span></div>
<div class="${sealClass(data.stampImageUrl)}">${sealMarkup(data.stampImageUrl, ['EDR', 'Warehouse', 'Handover'])}</div>
<div class="line">
${approval?.signatureImageUrl ? `<img class="signature-img" src="${esc(approval.signatureImageUrl)}" />` : ''}
<div class="signature-meta">${approval ? esc(approval.signerDisplayName) : 'Customer or driver name / signature / date'}</div>

View File

@@ -157,9 +157,6 @@ async function main() {
email: 'negad-indode-demo@edr.local',
contactPersonName: 'Marshalling Demo',
contactPersonPhone: '251900000202',
generalManagerName: 'Demo Manager',
generalManagerEmail: 'negad-indode-demo@edr.local',
generalManagerPhone: '251900000202',
}),
));

View File

@@ -247,9 +247,6 @@ export class ApprovedFirstLastMileDemoBookingsSeeder {
website: null,
contactPersonName: 'First Last Mile Demo',
contactPersonPhone: '251900000101',
generalManagerName: 'Demo Manager',
generalManagerEmail: COMPANY_EMAIL,
generalManagerPhone: '251900000101',
},
{ conflictPaths: { tin: true } },
);

View File

@@ -324,9 +324,6 @@ export class DemoBookingsSeeder {
website: null,
contactPersonName: "Train Scheduling",
contactPersonPhone: "251900000001",
generalManagerName: "Demo Manager",
generalManagerEmail: COMPANY_EMAIL,
generalManagerPhone: "251900000001",
},
{ conflictPaths: { tin: true } },
);

View File

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

View File

@@ -1,6 +1,7 @@
import { Injectable, Logger } from "@nestjs/common";
import { DataSource } from "typeorm";
import { FileUploadField } from "../modules/file-upload-settings/entities/file-upload-field.entity";
import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity";
import { poaDelegationField } from "../modules/file-upload-settings/poa-delegation.constants";
@@ -154,6 +155,56 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [
// },
// ];
/**
* Documents required from a co-operative union or farm — the third alternative
* to the two nationality sets, not an addition to them. A co-op is always
* registered in Ethiopia and has a TIN but no business licence, so its
* registration certificate stands in for the commercial registration every
* other Ethiopian company uploads.
*
* Admin-managed like every other onboarding set: what these members must
* actually produce is a backoffice decision, edited in the file-settings editor.
*/
const COOPERATIVE_ONBOARDING_FIELDS: OnboardingField[] = [
// First on purpose: only the first field of a new set is seeded, and this is
// the paper that distinguishes a co-operative from every other company.
{
fileKey: "cooperative_registration_certificate",
fileLabel: "Co-operative Union / Farm Registration Certificate",
helpText:
"Certificate issued by the co-operative promotion agency that registered the union or farm.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: DOC_EXTENSIONS,
maxSizeMb: 50,
displayOrder: 1,
},
{
fileKey: "tin_certificate",
fileLabel: "TIN Certificate",
helpText: "Verified against the TIN registry during registration.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: DOC_EXTENSIONS,
maxSizeMb: 50,
displayOrder: 2,
},
{
fileKey: "national_id",
fileLabel: "National ID",
helpText: "Verified against the National ID API during registration.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: DOC_EXTENSIONS,
maxSizeMb: 50,
displayOrder: 3,
},
poaDelegationDefault(4),
];
interface OnboardingDocumentSetting {
code: string;
label: string;
@@ -176,6 +227,14 @@ const COMPANY_ONBOARDING_DOCUMENTS: OnboardingDocumentSetting[] = [
entity: "customer",
fields: FOREIGN_ONBOARDING_FIELDS,
},
// The third set: a union or farm resolves here INSTEAD of a nationality set
// (it is always Ethiopian, and holds no business licence).
{
code: "company_onboarding_documents_cooperative",
label: "Co-operative union / farm onboarding documents",
entity: "customer",
fields: COOPERATIVE_ONBOARDING_FIELDS,
},
// Legacy per-company-type codes — removed, unused by any resolver or portal
// lookup (only `company_onboarding_documents_ethiopian`/`_foreign` are live).
// {
@@ -606,16 +665,15 @@ export class FileUploadSettingsSeeder {
async run() {
const settingRepository = this.dataSource.getRepository(FileUploadSetting);
// Seed only into an empty table: any existing rows (including
// soft-deleted ones, which would still conflict on the unique `code`)
// mean the data is admin-managed, so leave it untouched.
const existing = await settingRepository.count({ withDeleted: true });
if (existing > 0) {
this.logger.log(
`file_upload_settings already has ${existing} rows — skipping seed`,
);
return;
}
// Seed per CODE, not "only into an empty table". An existing row is
// admin-managed and never touched — including a soft-deleted one, which
// means the set was removed on purpose (and would still conflict on the
// unique `code`). What the table-wide check got wrong is the other half: a
// set added to this file after the first boot could never reach a database
// that already held the others, so it existed in code and nowhere else.
const existingCodes = new Set(
(await settingRepository.find({ withDeleted: true })).map((s) => s.code),
);
const allSettings: Array<
OnboardingDocumentSetting & { description: string }
@@ -645,20 +703,41 @@ export class FileUploadSettingsSeeder {
})),
];
// Insert setting rows only — no FileUploadField rows. Fields start empty
// and are configured from the backoffice file-settings editor; the field
// definitions above are kept as reference defaults.
await settingRepository.insert(
allSettings.map((documentSetting) => ({
code: documentSetting.code,
label: documentSetting.label,
description: documentSetting.description,
entity: documentSetting.entity,
})),
const missing = allSettings.filter((s) => !existingCodes.has(s.code));
if (missing.length === 0) {
this.logger.log("file upload settings up to date — nothing to seed");
return;
}
const inserted = await settingRepository.save(
missing.map((documentSetting) =>
settingRepository.create({
code: documentSetting.code,
label: documentSetting.label,
description: documentSetting.description,
entity: documentSetting.entity,
}),
),
);
// A brand-new set gets exactly ONE field: its first reference default. The
// rest of the list above stays documentation — what a set actually asks for
// is a backoffice decision, edited in the file-settings editor. Seeding one
// means a set is never born empty (an empty set silently requires nothing),
// while leaving the admin a single row to extend rather than a list to prune.
const fieldRepository = this.dataSource.getRepository(FileUploadField);
const firstFields = inserted.flatMap((setting) => {
const reference = missing.find((s) => s.code === setting.code)?.fields[0];
return reference
? [fieldRepository.create({ ...reference, settingId: setting.id })]
: [];
});
if (firstFields.length > 0) await fieldRepository.save(firstFields);
this.logger.log(
`Seeded ${allSettings.length} file upload settings with empty fields`,
`Seeded ${missing.length} file upload settings (${firstFields.length} with a default field): ${missing
.map((s) => s.code)
.join(", ")}`,
);
}
}

Some files were not shown because too many files have changed in this diff Show More